Compare commits
4 Commits
7364427cd5
...
agent/open
| Author | SHA1 | Date | |
|---|---|---|---|
| 62f01f1db3 | |||
| 95fb688910 | |||
| 33ff879e90 | |||
| 45afb302b7 |
@@ -21,6 +21,5 @@ REDIS_URL=
|
||||
AI_JOB_POLL_INTERVAL_MS=1000
|
||||
|
||||
# Alpha controls
|
||||
INVITE_ALLOWLIST=founder@example.com
|
||||
DAILY_AI_TOKEN_LIMIT=250000
|
||||
NUXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
|
||||
79
Dockerfile
Normal file
79
Dockerfile
Normal file
@@ -0,0 +1,79 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
#
|
||||
# Dungeons & Ground — production image.
|
||||
#
|
||||
# Build: docker build -t dungeons-ground .
|
||||
# Run: docker run --env-file .env -p 3000:3000 dungeons-ground
|
||||
#
|
||||
# Required env vars at runtime (see .env.example):
|
||||
# NUXT_PUBLIC_SUPABASE_URL, SUPABASE_URL, NUXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||
# SUPABASE_SERVICE_ROLE_KEY, plus the AI provider key (OPENROUTER_API_KEY
|
||||
# or DEEPSEEK_API_KEY). REDIS_URL is optional.
|
||||
# The Supabase schema is NOT created by the container; install
|
||||
# supabase/bootstrap.sql (or run `pnpm db:ensure` with SUPABASE_DB_URL set)
|
||||
# in your Supabase project before starting.
|
||||
|
||||
FROM node:18.20.8-slim AS base
|
||||
|
||||
ENV PNPM_HOME=/pnpm
|
||||
ENV PATH=$PNPM_HOME:$PATH
|
||||
RUN corepack enable && corepack prepare pnpm@10.15.0 --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# ---------- Dependencies ----------
|
||||
FROM base AS deps
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY apps/worker/package.json apps/worker/package.json
|
||||
COPY packages/shared/package.json packages/shared/package.json
|
||||
COPY packages/game-engine/package.json packages/game-engine/package.json
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ---------- Build ----------
|
||||
FROM deps AS build
|
||||
|
||||
COPY . .
|
||||
# The web build explicitly loads ../../.env (--dotenv); an empty file keeps
|
||||
# the build hermetic — all real values come from container env at runtime.
|
||||
RUN touch .env && pnpm --filter @dng/web build
|
||||
|
||||
# ---------- Runtime ----------
|
||||
FROM base AS runtime
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY apps/web/package.json apps/web/package.json
|
||||
COPY apps/worker/package.json apps/worker/package.json
|
||||
COPY packages/shared/package.json packages/shared/package.json
|
||||
COPY packages/game-engine/package.json packages/game-engine/package.json
|
||||
|
||||
COPY --from=deps /app/node_modules node_modules
|
||||
COPY --from=build /app/apps/web/.output apps/web/.output
|
||||
COPY --from=build /app/apps/worker apps/worker
|
||||
COPY --from=build /app/packages/shared packages/shared
|
||||
COPY --from=build /app/packages/game-engine packages/game-engine
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Coolify (and other hosts) override PORT at runtime; probe that port, not a
|
||||
# hardcoded one.
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + '/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
# The Nuxt web server reads its config through Nuxt runtimeConfig, which is
|
||||
# baked at build time and overridden at runtime only by NUXT_-prefixed env
|
||||
# vars; the worker reads plain names (SUPABASE_URL, OPENROUTER_API_KEY, ...)
|
||||
# directly. The entrypoint maps the plain .env.example names onto the NUXT_
|
||||
# runtime overrides so `docker run --env-file .env` works for both processes.
|
||||
# The web server runs in the foreground (PID 1); the AI worker runs alongside
|
||||
# it in the background. Stop the container with `docker stop` — both die with
|
||||
# it.
|
||||
CMD ["sh", "-c", "export NUXT_SUPABASE_URL=\"${NUXT_SUPABASE_URL:-${SUPABASE_URL:-}}\" NUXT_SUPABASE_SERVICE_ROLE_KEY=\"${NUXT_SUPABASE_SERVICE_ROLE_KEY:-${SUPABASE_SERVICE_ROLE_KEY:-}}\" NUXT_AI_PROVIDER=\"${NUXT_AI_PROVIDER:-${AI_PROVIDER:-}}\" NUXT_OPENROUTER_API_KEY=\"${NUXT_OPENROUTER_API_KEY:-${OPENROUTER_API_KEY:-}}\" NUXT_OPENROUTER_MODEL=\"${NUXT_OPENROUTER_MODEL:-${OPENROUTER_MODEL:-}}\" NUXT_OPENROUTER_API_ENDPOINT=\"${NUXT_OPENROUTER_API_ENDPOINT:-${OPENROUTER_API_ENDPOINT:-}}\" NUXT_DEEPSEEK_API_KEY=\"${NUXT_DEEPSEEK_API_KEY:-${DEEPSEEK_API_KEY:-}}\" NUXT_DEEPSEEK_MODEL=\"${NUXT_DEEPSEEK_MODEL:-${DEEPSEEK_MODEL:-}}\" NUXT_DEEPSEEK_API_ENDPOINT=\"${NUXT_DEEPSEEK_API_ENDPOINT:-${DEEPSEEK_API_ENDPOINT:-}}\" NUXT_REDIS_URL=\"${NUXT_REDIS_URL:-${REDIS_URL:-}}\"; pnpm --filter @dng/worker start & exec node apps/web/.output/server/index.mjs"]
|
||||
191
PLAN.md
191
PLAN.md
@@ -1 +1,192 @@
|
||||
# Dungeons & Ground — план закрытой web‑альфы
|
||||
|
||||
## 1. Цель продукта
|
||||
|
||||
Dungeons & Ground — англоязычная веб‑платформа для текстовых TTRPG‑кампаний любого жанра, где:
|
||||
|
||||
- AI‑соавтор помогает за несколько сообщений создать собственную вселенную.
|
||||
- AI Game Master ведёт историю, NPC и последствия.
|
||||
- Реальные игроки участвуют асинхронно в общем пошаговом чате.
|
||||
- Постоянные AI‑герои дополняют партию, а при необходимости временно заменяют отсутствующих игроков.
|
||||
- Сервер, а не языковая модель, рассчитывает проверки, броски, HP и урон.
|
||||
- Кампания сохраняет мир, отношения, инвентарь, события и долгосрочную память.
|
||||
|
||||
Главный критерий готовности альфы: 2–4 человека создают мир, персонажей и проходят минимум 20–30 минут связной кампании, причём AI не забывает ключевые события и не изменяет игровые значения произвольно.
|
||||
|
||||
В отличие от Friends & Fables, уже совмещающего AI‑ведущего, worldbuilding, память, карты, бой и мультиплеер, первая версия D&G сосредоточится на простом создании мира и партии, которая продолжает игру даже при отсутствии части людей. [Возможности Friends & Fables](https://fables.gg/), [описание их системы памяти](https://help.fables.gg/articles/2838157-memories).
|
||||
|
||||
## 2. Функциональность альфы
|
||||
|
||||
### Пользовательский путь
|
||||
|
||||
1. Регистрация обязательного аккаунта по имени, email и паролю; вход по email/password и восстановление пароля через письмо.
|
||||
2. Создание приватной вселенной через чат с AI‑соавтором.
|
||||
3. AI задаёт 3–5 уточняющих вопросов о жанре, атмосфере, конфликте и желаемой роли игроков.
|
||||
4. Создаётся редактируемый стартовый набор:
|
||||
- premise, genre, tone и content boundaries;
|
||||
- стартовая локация;
|
||||
- 3 значимых NPC;
|
||||
- 2 фракции;
|
||||
- сюжетная завязка;
|
||||
- скрытая угроза или цель;
|
||||
- начальная сцена.
|
||||
5. Владелец подтверждает мир, создаёт кампанию и приглашает игроков ссылкой.
|
||||
6. Каждый игрок создаёт персонажа вручную или с помощью AI.
|
||||
7. Владелец добавляет постоянных AI‑героев и определяет, чьи персонажи могут временно переходить под управление AI.
|
||||
8. Игроки отправляют действия в текущем раунде.
|
||||
9. AI‑DM обрабатывает раунд, когда:
|
||||
- ответили все активные реальные игроки; или
|
||||
- владелец нажал `Continue without waiting`.
|
||||
10. AI‑герои и замены совершают действия после людей, затем AI‑DM публикует единый результат раунда.
|
||||
|
||||
### Облегчённая игровая система
|
||||
|
||||
Использовать классические характеристики `STR`, `DEX`, `CON`, `INT`, `WIS`, `CHA` во всех жанрах, но позволять AI адаптировать названия архетипов, экипировки и способностей под fantasy, sci‑fi, horror и другие сеттинги.
|
||||
|
||||
Первая версия движка покрывает:
|
||||
|
||||
- d20 ability и skill checks;
|
||||
- proficiency bonus;
|
||||
- HP, Defense/AC и initiative;
|
||||
- attack roll, damage и healing;
|
||||
- advantage/disadvantage;
|
||||
- простые статусы и ограниченные ресурсы;
|
||||
- серверный генератор случайных чисел с сохранением формулы и результата;
|
||||
- ручную корректировку состояния владельцем кампании с записью в аудит.
|
||||
|
||||
Полные классы, заклинания, сетка боя и редкие правила 5e не входят в альфу. Основа — SRD 5.2.1 под CC BY 4.0 с обязательной атрибуцией; закрытые названия и сеттинги D&D не использовать. [Официальные условия SRD 5.2.1](https://www.dndbeyond.com/srd).
|
||||
|
||||
### Память и состояние мира
|
||||
|
||||
Хранить отдельно:
|
||||
|
||||
- неизменяемый журнал действий и результатов;
|
||||
- текущую сцену и активные сущности;
|
||||
- структурированное состояние персонажей, NPC, фракций и заданий;
|
||||
- отношения между персонажами;
|
||||
- важные воспоминания с привязкой к персонажам, локациям и тегам;
|
||||
- краткое резюме истории, обновляемое каждые три завершённых раунда.
|
||||
|
||||
В контекст модели передавать только системные инструкции, текущую сцену, последние два раунда, участвующие сущности, активные цели и релевантные воспоминания. Полная история никогда не отправляется автоматически.
|
||||
|
||||
## 3. Техническая архитектура
|
||||
|
||||
### Стек
|
||||
|
||||
- Monorepo на TypeScript.
|
||||
- Nuxt.js для SSR‑интерфейса и Nitro API.
|
||||
- Отдельный TypeScript worker для генерации миров и обработки игровых раундов.
|
||||
- Supabase: PostgreSQL, Auth, Row Level Security и Realtime.
|
||||
- BullMQ + managed Redis для очереди AI‑задач.
|
||||
- Railway: отдельные deployments для Nuxt/Nitro и worker.
|
||||
- OpenRouter provider adapter с моделью `deepseek/deepseek-v4-flash`.
|
||||
- Zod для общей валидации API, AI‑ответов и игровых команд.
|
||||
- Vitest, Nuxt Test Utils и Playwright для тестов.
|
||||
|
||||
Desktop‑версия позднее создаётся через Tauri и использует тот же API и web‑интерфейс.
|
||||
|
||||
### Основные сущности и интерфейсы
|
||||
|
||||
- `User`, `Invite`, `World`, `WorldEntity`, `Campaign`, `CampaignMember`.
|
||||
- `Character`, `CharacterController`, `Relationship`.
|
||||
- `Round`, `PlayerIntent`, `GameEvent`, `DiceRoll`.
|
||||
- `SceneState`, `QuestState`, `Memory`, `StorySummary`.
|
||||
- `AiJob`, `AiUsage`, `AuditEntry`.
|
||||
|
||||
Публичные серверные операции:
|
||||
|
||||
- создание и редактирование мира через coauthor session;
|
||||
- подтверждение сгенерированного мира;
|
||||
- создание кампании и приглашение участника;
|
||||
- создание/назначение персонажа;
|
||||
- отправка или изменение действия текущего раунда;
|
||||
- отметка игрока как готового;
|
||||
- принудительное закрытие раунда владельцем;
|
||||
- получение истории, состояния персонажа и активной сцены;
|
||||
- ручная корректировка состояния владельцем.
|
||||
|
||||
### Обработка раунда
|
||||
|
||||
1. Nitro сохраняет `PlayerIntent` и готовность игрока.
|
||||
2. Транзакция блокирует раунд и создаёт ровно одну задачу после выполнения условия закрытия.
|
||||
3. Worker собирает минимальный контекст.
|
||||
4. AI определяет намерения, нужные проверки и действия AI‑героев через типизированные tool calls.
|
||||
5. Rules engine выполняет все броски и изменения механического состояния.
|
||||
6. Результаты возвращаются AI для финальной narration.
|
||||
7. Предложенные события и изменения проходят Zod‑валидацию и проверку разрешённых переходов.
|
||||
8. Narration, события, память и новое состояние сохраняются атомарно.
|
||||
9. Supabase Realtime обновляет интерфейсы участников.
|
||||
|
||||
При timeout или ошибке OpenRouter задача повторяется с idempotency key. После исчерпания повторов раунд остаётся открываемым повторно владельцем; частичные изменения не сохраняются. OpenRouter поддерживает tool calling и JSON Schema outputs, которые следует использовать для типизированных AI‑операций. [Tool calling](https://openrouter.ai/docs/guides/features/tool-calling), [structured outputs](https://openrouter.ai/docs/guides/features/structured-outputs).
|
||||
|
||||
## 4. Дорожная карта на 8–10 недель
|
||||
|
||||
### Недели 1–2 — фундамент
|
||||
|
||||
- Monorepo, Nuxt/Nitro, worker, CI и окружения.
|
||||
- Supabase email/password Auth, email confirmation/recovery, RLS и базовая схема данных.
|
||||
- Англоязычный UI: login, dashboard, world list.
|
||||
- OpenRouter adapter, учёт токенов, дневные квоты и безопасное хранение ключа.
|
||||
- Первый вертикальный тест: действие игрока → worker → ответ AI.
|
||||
|
||||
### Недели 3–4 — вселенные и персонажи
|
||||
|
||||
- Coauthor chat и структурированная генерация стартового набора.
|
||||
- Preview/edit/confirm для мира.
|
||||
- Создание кампании и персонажей.
|
||||
- Приглашения, lobby, роли owner/player и AI‑герои.
|
||||
- 13+ фильтрация входных prompts и выходов модели.
|
||||
|
||||
### Недели 5–6 — игровой цикл
|
||||
|
||||
- Раунды, готовность участников и принудительное продолжение владельцем.
|
||||
- Realtime‑обновления.
|
||||
- Rules engine и журнал бросков.
|
||||
- AI‑DM orchestration, AI‑герои и временная замена отсутствующих игроков.
|
||||
- Защита от двойной обработки и конфликтующих действий.
|
||||
|
||||
### Недели 7–8 — память и стабильность
|
||||
|
||||
- Game events, relationships, memories и story summaries.
|
||||
- Контекстный retrieval без векторной базы: сущности, локации, теги и PostgreSQL full‑text search.
|
||||
- Retry, rate limits, usage dashboard и аудит изменений.
|
||||
- E2E‑сценарий полной партии и набор AI‑регрессионных тестов.
|
||||
|
||||
### Недели 9–10 — закрытая альфа
|
||||
|
||||
- UX‑полировка, responsive web и onboarding.
|
||||
- Наблюдаемость: ошибки, latency, стоимость раунда, размер контекста.
|
||||
- Тестирование командой и приглашёнными пользователями.
|
||||
- Исправление критических проблем и подготовка формы обратной связи.
|
||||
|
||||
## 5. Проверка и критерии приёмки
|
||||
|
||||
Обязательные сценарии:
|
||||
|
||||
- Создать fantasy, sci‑fi и horror‑миры через coauthor chat.
|
||||
- Отклонить или исправить неполную/невалидную AI‑структуру.
|
||||
- Два пользователя вступают в одну кампанию и видят одинаковое состояние.
|
||||
- Раунд ждёт всех активных людей.
|
||||
- Владелец продолжает без отсутствующего игрока, после чего его персонажа корректно подхватывает AI.
|
||||
- Постоянный AI‑герой сохраняет характер и отношения между сценами.
|
||||
- Броски воспроизводимо отображают формулу, модификатор и итог.
|
||||
- LLM не может напрямую изменить HP, инвентарь или результат броска.
|
||||
- Повторный запрос, reconnect или retry не создаёт второй narration.
|
||||
- OpenRouter timeout не повреждает состояние кампании.
|
||||
- Пользователь не видит чужие приватные миры и кампании.
|
||||
- 13+ фильтр блокирует запрещённый explicit‑контент.
|
||||
- После 15–20 раундов AI корректно вспоминает ключевое событие из начала игры.
|
||||
- Средняя стоимость и задержка раунда укладываются в установленные перед альфой квоты.
|
||||
|
||||
Перед приглашением внешних тестеров провести минимум 20 сценарных AI‑прогонов с заранее ожидаемыми фактами, правилами и последствиями.
|
||||
|
||||
## 6. Зафиксированные ограничения
|
||||
|
||||
- Название `Dungeons & Ground` считается финальным, но перед публичным релизом обязательны проверка товарных знаков, домена и визуального сходства.
|
||||
- Первая версия: web, English-first, обязательные email-аккаунты, private worlds и приглашения в кампании.
|
||||
- Контент: 13+, без explicit‑материалов.
|
||||
- OpenRouter оплачивает проект; пользователи не вводят собственные ключи.
|
||||
- Оплаты нет — используются дневные, пользовательские и кампанийные лимиты.
|
||||
- MVP полностью текстовый.
|
||||
- Не входят: публичный каталог, marketplace, карты, изображения, TTS, видеогенерация, полноценный 5e combat, нативный desktop/mobile и пользовательские ruleset.
|
||||
- После альфы приоритет определяется метриками: удержание кампаний, завершённые раунды, стоимость AI‑хода, частота ручных исправлений и качество памяти.
|
||||
|
||||
@@ -14,7 +14,7 @@ pnpm dev:all
|
||||
|
||||
Open `http://localhost:3000`. `pnpm dev:all` starts the Nuxt site and the AI worker in one terminal. To run them separately, use `pnpm dev` in the first terminal and `pnpm dev:worker` in the second.
|
||||
|
||||
To verify the complete hosted-Supabase multiplayer path (two temporary guests, invite, AI character draft, persistent AI companion, shared readiness, AI resolution, and the next round), keep `dev:all` running and execute `pnpm smoke:multiplayer` in another terminal.
|
||||
To verify the complete hosted-Supabase multiplayer path (two temporary email accounts, invite, AI character draft, persistent AI companion, shared readiness, AI resolution, and the next round), keep `dev:all` running and execute `pnpm smoke:multiplayer` in another terminal.
|
||||
|
||||
Before starting either process, the root commands check the Supabase schema. If it is missing and `SUPABASE_DB_URL` is configured, they transactionally create all D&G tables, functions, triggers, indexes, and policies from `supabase/bootstrap.sql`. Copy the **Session pooler** URI from the Supabase Dashboard **Connect** panel into the root `.env` and replace the password placeholder with the URL-encoded database password. The database URL is server-only and must never use a `NUXT_PUBLIC_` prefix.
|
||||
|
||||
@@ -47,7 +47,7 @@ Use Node.js 18.20.5 or newer. The lockfile pins the web toolchain to the Node 18
|
||||
## Production services
|
||||
|
||||
1. Copy the Supabase **Session pooler** connection URI from **Connect** into `SUPABASE_DB_URL`. `pnpm dev:all` now detects an empty project and creates the complete schema automatically. `pnpm db:ensure` runs the same guarded bootstrap without starting the app. Manual SQL Editor installation remains available through `supabase/bootstrap.sql`.
|
||||
2. In Supabase Auth settings, enable **Allow anonymous sign-ins**. In Auth URL Configuration, set the Site URL to `http://localhost:3000` and add `http://localhost:3000/auth/callback` as a Redirect URL. Email magic-link accounts remain optional and must be added to `public.allowlist`. Before external testing, enable CAPTCHA and review Supabase's anonymous sign-in rate limits so disposable guests cannot be used to evade AI quotas.
|
||||
2. In Supabase Auth settings, enable the **Email** provider and disable **Allow anonymous sign-ins**. In Auth URL Configuration, set the Site URL to `http://localhost:3000`; add `http://localhost:3000/auth/callback` and `http://localhost:3000/auth/reset-password` as Redirect URLs. Configure SMTP before external testing so confirmation and recovery email is delivered reliably. CAPTCHA and Auth rate limits remain recommended.
|
||||
3. Run `pnpm dev:worker` alongside the web process. Provision Redis only when BullMQ delivery is desired; otherwise the worker polls the Supabase outbox.
|
||||
4. Configure either `OPENROUTER_API_KEY` (default model `deepseek/deepseek-v4-flash`) or set `AI_PROVIDER=deepseek` with `DEEPSEEK_API_KEY` (default model `deepseek-v4-flash`, endpoint `https://api.deepseek.com/chat/completions`).
|
||||
|
||||
@@ -55,10 +55,10 @@ If the worker reports `POST /rest/v1/rpc/claim_ai_job 404` and `/rest/v1/profile
|
||||
|
||||
## Weeks 3–4 flow
|
||||
|
||||
1. Click **Enter the Alpha** to create a persistent guest session without email. Do not sign out or clear site storage unless you are prepared to lose that guest account. An allowlisted email magic link is still available as an alternative.
|
||||
1. Click **Enter the Alpha** to open registration. Every account requires a display name, email, password, and 13+ confirmation. Existing users sign in with email/password; forgotten passwords use the email recovery flow.
|
||||
2. Create a world in the dynamic coauthor chat. Unfinished conversations and generated drafts appear on the dashboard and resume after a reload.
|
||||
3. Review every starting-world field, including the owner-only hidden threat, then confirm it.
|
||||
4. Create a human character manually or ask the coauthor for an editable draft. Owners can add persistent AI companions the same way.
|
||||
5. The owner creates an expiring private invite link. Guest or allowlisted-email users join through `/join/:token`.
|
||||
5. The owner creates an expiring private invite link. Signed-in email users join through `/join/:token`.
|
||||
6. Players save actions and mark them ready. The final ready action queues the round automatically; the owner can also continue without waiting.
|
||||
7. The worker resolves server-owned rolls and state, publishes narration, and opens the next round. Visible campaign tabs synchronize every two seconds and immediately when the tab regains focus.
|
||||
|
||||
@@ -4,12 +4,12 @@ const { session, restore, signOut } = useDngAuth()
|
||||
onMounted(() => void restore())
|
||||
|
||||
const initials = computed(() => {
|
||||
const email = session.value?.user.email ?? ''
|
||||
return email.slice(0, 2).toUpperCase() || 'G'
|
||||
const displayName = session.value?.user.user_metadata?.display_name
|
||||
const source = typeof displayName === 'string' && displayName.trim() ? displayName : session.value?.user.email ?? ''
|
||||
return source.trim().split(/\s+/).map(part => part[0]).join('').slice(0, 2).toUpperCase() || 'D&G'
|
||||
})
|
||||
|
||||
async function leave() {
|
||||
if (session.value?.user.is_anonymous && !window.confirm('This guest account cannot be recovered after sign out. Leave anyway?')) return
|
||||
await signOut()
|
||||
await navigateTo('/')
|
||||
}
|
||||
@@ -22,7 +22,7 @@ async function leave() {
|
||||
<div class="topbar-center"><span class="status-dot" /> PRIVATE ALPHA <b v-if="section">/ {{ section }}</b></div>
|
||||
<div class="topbar-actions">
|
||||
<NuxtLink to="/dashboard" class="icon-link" aria-label="Dashboard">⌂</NuxtLink>
|
||||
<button v-if="session" class="avatar" :title="session.user.is_anonymous ? 'Guest session — sign out' : `Sign out ${session.user.email ?? ''}`" @click="leave">{{ initials }}</button>
|
||||
<button v-if="session" class="avatar" :title="`Sign out ${session.user.email ?? ''}`" @click="leave">{{ initials }}</button>
|
||||
<div v-else class="avatar">D&G</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
28
apps/web/components/AuthCard.vue
Normal file
28
apps/web/components/AuthCard.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
eyebrow: string
|
||||
title: string
|
||||
subtitle: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="auth-page noise">
|
||||
<div class="brand"><AppMark /></div>
|
||||
<section class="auth-card">
|
||||
<header>
|
||||
<small>{{ eyebrow }}</small>
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ subtitle }}</p>
|
||||
</header>
|
||||
<slot />
|
||||
<footer><slot name="footer" /></footer>
|
||||
</section>
|
||||
<p class="security-note"><i /> PRIVATE WORLDS · SECURE SUPABASE ACCOUNT</p>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-page{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:28px;padding:42px 20px}.auth-card{width:min(480px,calc(100vw - 40px));padding:clamp(28px,5vw,48px);border:1px solid var(--line);background:linear-gradient(145deg,#11110f,#090909);box-shadow:0 35px 100px rgba(0,0,0,.48)}header small{font:600 8px var(--mono);letter-spacing:.18em;color:var(--acid)}header h1{margin:14px 0 12px;font:600 clamp(32px,6vw,52px)/1 var(--display);letter-spacing:-.06em}header p{margin:0 0 30px;color:var(--muted);font-size:12px;line-height:1.7}footer{margin-top:25px;padding-top:22px;border-top:1px solid var(--line);text-align:center;color:var(--muted);font-size:11px}.security-note{font:500 7px var(--mono);letter-spacing:.13em;color:var(--muted)}.security-note i{display:inline-block;width:5px;height:5px;margin-right:8px;border-radius:50%;background:var(--acid)}
|
||||
:deep(.auth-form){display:grid;gap:15px}:deep(.auth-form label){display:grid;gap:8px;font:600 8px var(--mono);letter-spacing:.11em;color:#b5b5ae}:deep(.auth-form input){box-sizing:border-box;width:100%;min-height:48px;padding:0 14px;border:1px solid #373732;background:#0a0a09;color:var(--ink);font:12px var(--body);outline:none}:deep(.auth-form input:focus){border-color:var(--acid)}:deep(.auth-form button){min-height:50px;border:0;background:var(--acid);color:#080808;font:700 9px var(--mono);letter-spacing:.12em}:deep(.auth-form button:disabled){opacity:.45;cursor:wait}:deep(.form-error),:deep(.form-success){margin:0;padding:12px 14px;border:1px solid #6b372e;background:#221310;color:#ffab98;font:500 9px/1.55 var(--mono)}:deep(.form-success){border-color:#526425;background:#151a0d;color:var(--acid)}:deep(.form-link){color:var(--acid);text-decoration:none;font-weight:600}:deep(.field-row){display:flex;align-items:center;justify-content:space-between}:deep(.field-row a){color:var(--muted);font:500 8px var(--mono)}
|
||||
</style>
|
||||
@@ -19,6 +19,13 @@ interface AuthResponse {
|
||||
user: DngAuthUser
|
||||
}
|
||||
|
||||
interface SignUpResponse {
|
||||
access_token?: string | null
|
||||
refresh_token?: string | null
|
||||
expires_in?: number | null
|
||||
user: DngAuthUser
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'dng-auth-session'
|
||||
|
||||
export function useDngAuth() {
|
||||
@@ -48,6 +55,10 @@ export function useDngAuth() {
|
||||
}
|
||||
}
|
||||
|
||||
function isEmailAccount(user: DngAuthUser | undefined): boolean {
|
||||
return Boolean(user?.email && !user.is_anonymous)
|
||||
}
|
||||
|
||||
function parseStoredSession(value: string): DngSession | null {
|
||||
try {
|
||||
const saved = JSON.parse(value) as Partial<DngSession>
|
||||
@@ -97,7 +108,11 @@ export function useDngAuth() {
|
||||
}
|
||||
|
||||
async function restore() {
|
||||
if (!import.meta.client || hydrated.value) return session.value
|
||||
if (!import.meta.client) return session.value
|
||||
if (hydrated.value) {
|
||||
if (session.value && !isEmailAccount(session.value.user)) persist(null)
|
||||
return session.value
|
||||
}
|
||||
|
||||
let restoredFromStorage = false
|
||||
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ''))
|
||||
@@ -133,37 +148,76 @@ export function useDngAuth() {
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
if (session.value && !isEmailAccount(session.value.user)) persist(null)
|
||||
hydrated.value = true
|
||||
return session.value
|
||||
}
|
||||
|
||||
async function requestMagicLink(email: string) {
|
||||
async function signUp(displayName: string, email: string, password: string) {
|
||||
const redirectTo = `${window.location.origin}/auth/callback`
|
||||
await $fetch(`${config.public.supabaseUrl}/auth/v1/otp?redirect_to=${encodeURIComponent(redirectTo)}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: { email: email.trim().toLowerCase(), create_user: true },
|
||||
})
|
||||
}
|
||||
|
||||
async function signInAnonymously() {
|
||||
const response = await $fetch<AuthResponse>(`${config.public.supabaseUrl}/auth/v1/signup`, {
|
||||
const response = await $fetch<SignUpResponse>(`${config.public.supabaseUrl}/auth/v1/signup?redirect_to=${encodeURIComponent(redirectTo)}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: {
|
||||
data: { display_name: 'Guest Adventurer' },
|
||||
gotrue_meta_security: {},
|
||||
email: email.trim().toLowerCase(),
|
||||
password,
|
||||
data: { display_name: displayName.trim() },
|
||||
},
|
||||
})
|
||||
if (response.access_token && response.refresh_token && response.expires_in) {
|
||||
const next = fromResponse({
|
||||
access_token: response.access_token,
|
||||
refresh_token: response.refresh_token,
|
||||
expires_in: response.expires_in,
|
||||
user: response.user,
|
||||
})
|
||||
persist(next)
|
||||
hydrated.value = true
|
||||
return { session: next, needsEmailConfirmation: false }
|
||||
}
|
||||
return { session: null, needsEmailConfirmation: true }
|
||||
}
|
||||
|
||||
async function signIn(email: string, password: string) {
|
||||
const response = await $fetch<AuthResponse>(`${config.public.supabaseUrl}/auth/v1/token?grant_type=password`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: { email: email.trim().toLowerCase(), password },
|
||||
})
|
||||
const next = fromResponse(response)
|
||||
if (!isEmailAccount(next.user)) throw new Error('An email account is required.')
|
||||
persist(next)
|
||||
hydrated.value = true
|
||||
return next
|
||||
}
|
||||
|
||||
async function requestPasswordReset(email: string) {
|
||||
const redirectTo = `${window.location.origin}/auth/reset-password`
|
||||
await $fetch(`${config.public.supabaseUrl}/auth/v1/recover?redirect_to=${encodeURIComponent(redirectTo)}`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: { email: email.trim().toLowerCase() },
|
||||
})
|
||||
}
|
||||
|
||||
async function updatePassword(password: string) {
|
||||
const token = await accessToken()
|
||||
const user = normalizeUser(await $fetch<DngAuthUser>(`${config.public.supabaseUrl}/auth/v1/user`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(token),
|
||||
body: { password },
|
||||
}))
|
||||
if (session.value) persist({ ...session.value, user })
|
||||
return user
|
||||
}
|
||||
|
||||
async function accessToken() {
|
||||
await restore()
|
||||
if (!session.value) throw new Error('Enter the alpha to continue.')
|
||||
if (!isEmailAccount(session.value.user)) {
|
||||
persist(null)
|
||||
throw new Error('Sign in with an email account to continue.')
|
||||
}
|
||||
if (session.value.expiresAt <= Date.now() + 60_000) await refresh()
|
||||
if (!session.value) throw new Error('Your session expired. Sign in again.')
|
||||
return session.value.accessToken
|
||||
@@ -184,5 +238,5 @@ export function useDngAuth() {
|
||||
hydrated.value = true
|
||||
}
|
||||
|
||||
return { session, hydrated, restore, refresh, requestMagicLink, signInAnonymously, accessToken, signOut, invalidate }
|
||||
return { session, hydrated, restore, refresh, signUp, signIn, requestPasswordReset, updatePassword, accessToken, signOut, invalidate }
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div class="callback noise">
|
||||
<AppMark />
|
||||
<p v-if="!error">VERIFYING YOUR SIGNAL…</p>
|
||||
<template v-else><p class="error">{{ error }}</p><NuxtLink to="/">REQUEST A NEW LINK</NuxtLink></template>
|
||||
<p v-if="!error">VERIFYING YOUR EMAIL…</p>
|
||||
<template v-else><p class="error">{{ error }}</p><NuxtLink to="/auth/sign-in">BACK TO SIGN IN</NuxtLink></template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
33
apps/web/pages/auth/forgot-password.vue
Normal file
33
apps/web/pages/auth/forgot-password.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
const { requestPasswordReset } = useDngAuth()
|
||||
const email = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const sent = ref(false)
|
||||
|
||||
async function submit() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await requestPasswordReset(email.value)
|
||||
sent.value = true
|
||||
} catch (cause) {
|
||||
const value = cause as { data?: { msg?: string; message?: string }; message?: string }
|
||||
error.value = value.data?.msg ?? value.data?.message ?? value.message ?? 'Could not send the reset email.'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthCard eyebrow="ACCOUNT RECOVERY" title="RESET THE SIGNAL." subtitle="Enter your account email. If it exists, Supabase will send a secure password reset link.">
|
||||
<p v-if="sent" class="form-success" role="status">CHECK YOUR EMAIL. The recovery link can be used once and expires automatically.</p>
|
||||
<form v-else class="auth-form" @submit.prevent="submit">
|
||||
<label>EMAIL<input v-model="email" name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>
|
||||
<p v-if="error" class="form-error" role="alert">{{ error }}</p>
|
||||
<button :disabled="busy">{{ busy ? 'SENDING…' : 'SEND RESET LINK →' }}</button>
|
||||
</form>
|
||||
<template #footer><NuxtLink class="form-link" to="/auth/sign-in">← BACK TO SIGN IN</NuxtLink></template>
|
||||
</AuthCard>
|
||||
</template>
|
||||
54
apps/web/pages/auth/reset-password.vue
Normal file
54
apps/web/pages/auth/reset-password.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
const auth = useDngAuth()
|
||||
const password = ref('')
|
||||
const confirmation = ref('')
|
||||
const busy = ref(false)
|
||||
const ready = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const session = await auth.restore()
|
||||
if (!session) throw new Error('This recovery link is invalid or expired.')
|
||||
ready.value = true
|
||||
} catch (cause) {
|
||||
error.value = cause instanceof Error ? cause.message : 'Could not verify the recovery link.'
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
if (password.value.length < 8) {
|
||||
error.value = 'Password must contain at least 8 characters.'
|
||||
return
|
||||
}
|
||||
if (password.value !== confirmation.value) {
|
||||
error.value = 'Passwords do not match.'
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
await auth.updatePassword(password.value)
|
||||
await navigateTo('/dashboard', { replace: true })
|
||||
} catch (cause) {
|
||||
const value = cause as { data?: { msg?: string; message?: string }; message?: string }
|
||||
error.value = value.data?.msg ?? value.data?.message ?? value.message ?? 'Could not update the password.'
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthCard eyebrow="SECURE RECOVERY" title="CHOOSE A NEW PASSWORD." subtitle="Set a new password for your Dungeons & Ground account.">
|
||||
<p v-if="!ready && !error" class="form-success">VERIFYING RECOVERY LINK…</p>
|
||||
<form v-if="ready" class="auth-form" @submit.prevent="submit">
|
||||
<label>NEW PASSWORD<input v-model="password" name="password" type="password" autocomplete="new-password" minlength="8" required placeholder="At least 8 characters"></label>
|
||||
<label>CONFIRM PASSWORD<input v-model="confirmation" name="password-confirmation" type="password" autocomplete="new-password" minlength="8" required placeholder="Repeat your password"></label>
|
||||
<p v-if="error" class="form-error" role="alert">{{ error }}</p>
|
||||
<button :disabled="busy">{{ busy ? 'UPDATING…' : 'UPDATE PASSWORD →' }}</button>
|
||||
</form>
|
||||
<p v-else-if="error" class="form-error" role="alert">{{ error }}</p>
|
||||
<template #footer><NuxtLink class="form-link" to="/auth/sign-in">BACK TO SIGN IN</NuxtLink></template>
|
||||
</AuthCard>
|
||||
</template>
|
||||
45
apps/web/pages/auth/sign-in.vue
Normal file
45
apps/web/pages/auth/sign-in.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
const auth = useDngAuth()
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
function messageFrom(cause: unknown) {
|
||||
const value = cause as { data?: { msg?: string; message?: string; error_description?: string }; message?: string }
|
||||
return value.data?.msg ?? value.data?.message ?? value.data?.error_description ?? value.message ?? 'Sign-in failed. Check your email and password.'
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await auth.signIn(email.value, password.value)
|
||||
const requested = localStorage.getItem('dng-post-auth-redirect')
|
||||
localStorage.removeItem('dng-post-auth-redirect')
|
||||
const destination = requested?.startsWith('/') && !requested.startsWith('//') ? requested : '/dashboard'
|
||||
await navigateTo(destination, { replace: true })
|
||||
} catch (cause) {
|
||||
error.value = messageFrom(cause)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.restore()
|
||||
if (auth.session.value) await navigateTo('/dashboard', { replace: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthCard eyebrow="ACCOUNT ACCESS" title="WELCOME BACK." subtitle="Sign in to continue your private worlds and shared campaigns.">
|
||||
<form class="auth-form" @submit.prevent="submit">
|
||||
<label>EMAIL<input v-model="email" name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>
|
||||
<label><span class="field-row"><span>PASSWORD</span><NuxtLink to="/auth/forgot-password">FORGOT?</NuxtLink></span><input v-model="password" name="password" type="password" autocomplete="current-password" required placeholder="Your password"></label>
|
||||
<p v-if="error" class="form-error" role="alert">{{ error }}</p>
|
||||
<button :disabled="busy">{{ busy ? 'SIGNING IN…' : 'SIGN IN →' }}</button>
|
||||
</form>
|
||||
<template #footer>New to D&G? <NuxtLink class="form-link" to="/auth/sign-up">CREATE ACCOUNT</NuxtLink></template>
|
||||
</AuthCard>
|
||||
</template>
|
||||
80
apps/web/pages/auth/sign-up.vue
Normal file
80
apps/web/pages/auth/sign-up.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
const auth = useDngAuth()
|
||||
const displayName = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirmation = ref('')
|
||||
const ageConfirmed = ref(false)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const confirmationSent = ref(false)
|
||||
|
||||
function messageFrom(cause: unknown) {
|
||||
const value = cause as { data?: { msg?: string; message?: string; error_description?: string }; message?: string }
|
||||
return value.data?.msg ?? value.data?.message ?? value.data?.error_description ?? value.message ?? 'Registration failed. Try again.'
|
||||
}
|
||||
|
||||
async function finishSignIn() {
|
||||
const requested = localStorage.getItem('dng-post-auth-redirect')
|
||||
localStorage.removeItem('dng-post-auth-redirect')
|
||||
const destination = requested?.startsWith('/') && !requested.startsWith('//') ? requested : '/dashboard'
|
||||
await navigateTo(destination, { replace: true })
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
if (displayName.value.trim().length < 2) {
|
||||
error.value = 'Enter a name with at least 2 characters.'
|
||||
return
|
||||
}
|
||||
if (password.value.length < 8) {
|
||||
error.value = 'Password must contain at least 8 characters.'
|
||||
return
|
||||
}
|
||||
if (password.value !== confirmation.value) {
|
||||
error.value = 'Passwords do not match.'
|
||||
return
|
||||
}
|
||||
if (!ageConfirmed.value) {
|
||||
error.value = 'You must confirm that you are at least 13 years old.'
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
const result = await auth.signUp(displayName.value, email.value, password.value)
|
||||
if (result.session) await finishSignIn()
|
||||
else confirmationSent.value = true
|
||||
} catch (cause) {
|
||||
error.value = messageFrom(cause)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.restore()
|
||||
if (auth.session.value) await navigateTo('/dashboard', { replace: true })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthCard eyebrow="CREATE YOUR ACCOUNT" title="ENTER THE ALPHA." subtitle="Your email keeps your worlds, characters, and campaign access recoverable across devices.">
|
||||
<div v-if="confirmationSent" class="form-success" role="status">
|
||||
CHECK YOUR EMAIL. We sent a confirmation link to {{ email }}. Open it to activate your account.
|
||||
</div>
|
||||
<form v-else class="auth-form" @submit.prevent="submit">
|
||||
<label>DISPLAY NAME<input v-model="displayName" name="name" autocomplete="name" maxlength="80" required placeholder="How your party sees you"></label>
|
||||
<label>EMAIL<input v-model="email" name="email" type="email" autocomplete="email" required placeholder="you@example.com"></label>
|
||||
<label>PASSWORD<input v-model="password" name="password" type="password" autocomplete="new-password" minlength="8" required placeholder="At least 8 characters"></label>
|
||||
<label>CONFIRM PASSWORD<input v-model="confirmation" name="password-confirmation" type="password" autocomplete="new-password" minlength="8" required placeholder="Repeat your password"></label>
|
||||
<label class="consent"><input v-model="ageConfirmed" type="checkbox" required><span>I am at least 13 years old and accept the 13+ content boundary.</span></label>
|
||||
<p v-if="error" class="form-error" role="alert">{{ error }}</p>
|
||||
<button :disabled="busy">{{ busy ? 'CREATING ACCOUNT…' : 'CREATE ACCOUNT →' }}</button>
|
||||
</form>
|
||||
<template #footer>Already have an account? <NuxtLink class="form-link" to="/auth/sign-in">SIGN IN</NuxtLink></template>
|
||||
</AuthCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.consent{grid-template-columns:18px 1fr!important;align-items:start!important;font:400 10px/1.5 var(--body)!important;letter-spacing:0!important;color:var(--muted)!important}.consent input{width:16px!important;min-height:16px!important;margin:1px 0 0;padding:0!important;accent-color:var(--acid)}
|
||||
</style>
|
||||
@@ -74,7 +74,7 @@ onMounted(async () => {
|
||||
|
||||
<section class="world-grid">
|
||||
<p v-if="loading" class="load-state">RECEIVING PRIVATE CAMPAIGNS…</p>
|
||||
<p v-else-if="error" class="load-state error">{{ error }} <NuxtLink to="/">SIGN IN AGAIN</NuxtLink></p>
|
||||
<p v-else-if="error" class="load-state error">{{ error }} <NuxtLink to="/auth/sign-in">SIGN IN AGAIN</NuxtLink></p>
|
||||
<NuxtLink v-for="campaign in visibleCampaigns" :key="campaign.id" :to="`/campaign/${campaign.id}`" class="world-card active-world">
|
||||
<div class="world-art"><div class="eclipse" /><span>{{ campaign.status.toUpperCase() }} CAMPAIGN</span></div>
|
||||
<div class="world-info"><small>PRIVATE MULTIPLAYER</small><h2>{{ campaign.title }}</h2><p>{{ campaign.current_scene }}</p><div><b>CONTINUE STORY</b><span>{{ new Date(campaign.updated_at).toLocaleDateString() }}</span></div></div>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
const { session, restore, requestMagicLink, signInAnonymously } = useDngAuth()
|
||||
const email = ref('')
|
||||
const notice = ref('')
|
||||
const guestError = ref('')
|
||||
const sending = ref(false)
|
||||
const { session, restore } = useDngAuth()
|
||||
const entering = ref(false)
|
||||
|
||||
onMounted(() => void restore())
|
||||
@@ -11,42 +7,13 @@ onMounted(() => void restore())
|
||||
async function enterAlpha() {
|
||||
if (entering.value) return
|
||||
entering.value = true
|
||||
notice.value = ''
|
||||
guestError.value = ''
|
||||
try {
|
||||
await restore()
|
||||
if (!session.value) await signInAnonymously()
|
||||
const requested = localStorage.getItem('dng-post-auth-redirect')
|
||||
localStorage.removeItem('dng-post-auth-redirect')
|
||||
const destination = requested?.startsWith('/') && !requested.startsWith('//') ? requested : '/dashboard'
|
||||
await navigateTo(destination)
|
||||
} catch (cause) {
|
||||
const error = cause as { data?: { msg?: string; message?: string }; message?: string }
|
||||
const rawMessage = error.data?.msg ?? error.data?.message ?? error.message ?? ''
|
||||
guestError.value = /anonymous.*(disabled|sign.?in)/i.test(rawMessage)
|
||||
? 'Guest access is disabled in Supabase Auth settings.'
|
||||
: /database error|saving new user/i.test(rawMessage)
|
||||
? 'The Supabase database migration is incomplete. Apply bootstrap.sql.'
|
||||
: rawMessage || 'Guest access is unavailable right now.'
|
||||
await navigateTo(session.value ? '/dashboard' : '/auth/sign-up')
|
||||
} finally {
|
||||
entering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function requestAccess() {
|
||||
if (sending.value) return
|
||||
sending.value = true
|
||||
notice.value = ''
|
||||
try {
|
||||
await requestMagicLink(email.value)
|
||||
notice.value = `Magic link sent to ${email.value}. Check your inbox.`
|
||||
} catch (cause) {
|
||||
const error = cause as { data?: { msg?: string; message?: string }; message?: string }
|
||||
notice.value = error.data?.msg ?? error.data?.message ?? error.message ?? 'This email is not on the alpha allowlist.'
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -65,7 +32,6 @@ async function requestAccess() {
|
||||
<button class="btn btn-acid" :disabled="entering" @click="enterAlpha">{{ entering ? 'OPENING…' : session ? 'OPEN DASHBOARD' : 'ENTER THE ALPHA' }} <span>↗</span></button>
|
||||
<a href="#how" class="btn btn-ghost">SEE HOW IT WORKS</a>
|
||||
</div>
|
||||
<p v-if="guestError" class="guest-error" role="alert">{{ guestError }}</p>
|
||||
<div class="trust-row">
|
||||
<span>PRIVATE WORLDS</span><i />
|
||||
<span>SERVER-OWNED DICE</span><i />
|
||||
@@ -88,11 +54,11 @@ async function requestAccess() {
|
||||
<article><b>01</b><h2>DESCRIBE THE IMPOSSIBLE</h2><p>A coauthor asks sharp questions, then turns your idea into a playable private world.</p></article>
|
||||
<article><b>02</b><h2>ASSEMBLE YOUR PARTY</h2><p>Invite friends, add AI companions, and decide who can step in when someone is away.</p></article>
|
||||
<article><b>03</b><h2>ACT ON YOUR TIME</h2><p>The Groundkeeper resolves each shared round only when the party is ready.</p></article>
|
||||
<form @submit.prevent="requestAccess"><label for="email">OPTIONAL EMAIL SIGN-IN</label><div><input id="email" v-model="email" type="email" autocomplete="email" placeholder="you@example.com" required><button :disabled="sending">{{ sending ? '…' : '→' }}</button></div><small>{{ notice || 'No email is needed. Use this only for an invited email account.' }}</small></form>
|
||||
<aside class="account-card"><b>ACCOUNT REQUIRED</b><h2>KEEP YOUR WORLDS.</h2><p>Register with your name, email, and password. Continue on any device and recover access when needed.</p><div><NuxtLink to="/auth/sign-up">CREATE ACCOUNT →</NuxtLink><NuxtLink to="/auth/sign-in">SIGN IN</NuxtLink></div></aside>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.landing{min-height:100vh;overflow:hidden}.landing-nav{height:90px;padding:0 clamp(22px,6vw,90px);display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line)}.alpha-tag{font:500 9px var(--mono);letter-spacing:.18em;border:1px solid var(--line);padding:10px 14px;color:var(--muted)}.hero{min-height:680px;padding:72px clamp(22px,6vw,90px) 60px;display:grid;grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr);gap:7vw;align-items:center}.eyebrow{font:500 10px var(--mono);letter-spacing:.22em;color:var(--muted)}.eyebrow span{display:inline-block;width:26px;height:1px;background:var(--acid);vertical-align:middle;margin-right:10px}.hero h1{margin:22px 0 28px;font:600 clamp(52px,6.6vw,108px)/.91 var(--display);letter-spacing:-.07em}.hero h1 em{font-style:normal;color:transparent;-webkit-text-stroke:1px var(--acid)}.lede{max-width:650px;font-size:clamp(16px,1.5vw,20px);line-height:1.7;color:#b9b9b2}.hero-actions{display:flex;gap:12px;margin-top:38px}.guest-error{max-width:620px;margin:14px 0 0;color:#ff9d85;font:500 10px/1.5 var(--mono);letter-spacing:.04em}.btn{min-height:52px;padding:0 22px;border:1px solid var(--line);font:600 10px var(--mono);letter-spacing:.13em;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:20px}.btn:disabled{opacity:.7;cursor:wait}.btn-acid{border-color:var(--acid);background:var(--acid);color:#080808}.btn-ghost{color:var(--ink);background:transparent}.trust-row{display:flex;align-items:center;gap:14px;margin-top:42px;font:500 8px var(--mono);letter-spacing:.16em;color:var(--muted)}.trust-row i{width:3px;height:3px;background:var(--acid);border-radius:50%}.signal-card{position:relative;min-height:510px;border:1px solid var(--line);background:linear-gradient(145deg,#111 0%,#090909 60%);box-shadow:0 40px 120px #000;padding:22px;clip-path:polygon(0 0,calc(100% - 20px) 0,100% 20px,100% 100%,20px 100%,0 calc(100% - 20px))}.signal-top,.party-status{display:flex;justify-content:space-between;font:500 8px var(--mono);letter-spacing:.13em;color:var(--muted)}.signal-top b{color:var(--acid)}.orbit{position:relative;width:245px;height:245px;margin:48px auto 30px;border:1px solid #2b2b29;border-radius:50%;display:grid;place-items:center}.orbit:before,.orbit:after{content:"";position:absolute;border:1px solid #262624;border-radius:50%;inset:28px}.orbit:after{inset:61px;border-color:var(--acid-dim)}.orbit i{position:absolute;width:7px;height:7px;border-radius:50%;background:var(--acid);box-shadow:0 0 18px var(--acid)}.orbit i:nth-child(1){top:23px;left:52px}.orbit i:nth-child(2){right:-3px;top:113px}.orbit i:nth-child(3){bottom:20px;left:85px}.orbit span{font:600 28px var(--display);color:var(--acid)}.transmission{border-left:2px solid var(--acid);padding:8px 18px;margin:0 12px 34px}.transmission small{font:500 8px var(--mono);letter-spacing:.18em;color:var(--acid)}.transmission p{font:500 15px/1.6 var(--body);color:#d6d6d0}.party-status{padding:18px 12px 0;border-top:1px solid var(--line)}.party-status b{color:var(--acid)}.how-grid{display:grid;grid-template-columns:repeat(3,1fr) 1.2fr;border-top:1px solid var(--line)}.how-grid article,.how-grid form{min-height:215px;padding:34px;border-right:1px solid var(--line)}.how-grid article>b{font:500 9px var(--mono);color:var(--acid)}.how-grid h2{margin:34px 0 12px;font:600 13px var(--display);letter-spacing:-.03em}.how-grid p,.how-grid small{font-size:12px;line-height:1.6;color:var(--muted)}.how-grid label{font:600 10px var(--mono);letter-spacing:.12em}.how-grid form div{display:flex;margin:35px 0 12px}.how-grid input{width:100%;background:#10100f;border:1px solid var(--line);padding:14px;color:var(--ink)}.how-grid form button{width:50px;border:0;background:var(--acid);font-size:20px}@media(max-width:950px){.hero{grid-template-columns:1fr}.signal-card{max-width:620px}.how-grid{grid-template-columns:1fr 1fr}}@media(max-width:620px){.alpha-tag{display:none}.hero{padding-top:50px}.hero h1{font-size:48px}.hero-actions{flex-direction:column}.trust-row{flex-wrap:wrap}.signal-card{min-height:470px}.how-grid{grid-template-columns:1fr}.how-grid article,.how-grid form{border-bottom:1px solid var(--line)}}
|
||||
.landing{min-height:100vh;overflow:hidden}.landing-nav{height:90px;padding:0 clamp(22px,6vw,90px);display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid var(--line)}.alpha-tag{font:500 9px var(--mono);letter-spacing:.18em;border:1px solid var(--line);padding:10px 14px;color:var(--muted)}.hero{min-height:680px;padding:72px clamp(22px,6vw,90px) 60px;display:grid;grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr);gap:7vw;align-items:center}.eyebrow{font:500 10px var(--mono);letter-spacing:.22em;color:var(--muted)}.eyebrow span{display:inline-block;width:26px;height:1px;background:var(--acid);vertical-align:middle;margin-right:10px}.hero h1{margin:22px 0 28px;font:600 clamp(52px,6.6vw,108px)/.91 var(--display);letter-spacing:-.07em}.hero h1 em{font-style:normal;color:transparent;-webkit-text-stroke:1px var(--acid)}.lede{max-width:650px;font-size:clamp(16px,1.5vw,20px);line-height:1.7;color:#b9b9b2}.hero-actions{display:flex;gap:12px;margin-top:38px}.btn{min-height:52px;padding:0 22px;border:1px solid var(--line);font:600 10px var(--mono);letter-spacing:.13em;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:20px}.btn:disabled{opacity:.7;cursor:wait}.btn-acid{border-color:var(--acid);background:var(--acid);color:#080808}.btn-ghost{color:var(--ink);background:transparent}.trust-row{display:flex;align-items:center;gap:14px;margin-top:42px;font:500 8px var(--mono);letter-spacing:.16em;color:var(--muted)}.trust-row i{width:3px;height:3px;background:var(--acid);border-radius:50%}.signal-card{position:relative;min-height:510px;border:1px solid var(--line);background:linear-gradient(145deg,#111 0%,#090909 60%);box-shadow:0 40px 120px #000;padding:22px;clip-path:polygon(0 0,calc(100% - 20px) 0,100% 20px,100% 100%,20px 100%,0 calc(100% - 20px))}.signal-top,.party-status{display:flex;justify-content:space-between;font:500 8px var(--mono);letter-spacing:.13em;color:var(--muted)}.signal-top b{color:var(--acid)}.orbit{position:relative;width:245px;height:245px;margin:48px auto 30px;border:1px solid #2b2b29;border-radius:50%;display:grid;place-items:center}.orbit:before,.orbit:after{content:"";position:absolute;border:1px solid #262624;border-radius:50%;inset:28px}.orbit:after{inset:61px;border-color:var(--acid-dim)}.orbit i{position:absolute;width:7px;height:7px;border-radius:50%;background:var(--acid);box-shadow:0 0 18px var(--acid)}.orbit i:nth-child(1){top:23px;left:52px}.orbit i:nth-child(2){right:-3px;top:113px}.orbit i:nth-child(3){bottom:20px;left:85px}.orbit span{font:600 28px var(--display);color:var(--acid)}.transmission{border-left:2px solid var(--acid);padding:8px 18px;margin:0 12px 34px}.transmission small{font:500 8px var(--mono);letter-spacing:.18em;color:var(--acid)}.transmission p{font:500 15px/1.6 var(--body);color:#d6d6d0}.party-status{padding:18px 12px 0;border-top:1px solid var(--line)}.party-status b{color:var(--acid)}.how-grid{display:grid;grid-template-columns:repeat(3,1fr) 1.2fr;border-top:1px solid var(--line)}.how-grid article,.account-card{min-height:215px;padding:34px;border-right:1px solid var(--line)}.how-grid article>b,.account-card>b{font:500 9px var(--mono);color:var(--acid)}.how-grid h2{margin:34px 0 12px;font:600 13px var(--display);letter-spacing:-.03em}.how-grid p{font-size:12px;line-height:1.6;color:var(--muted)}.account-card{display:flex;flex-direction:column}.account-card h2{margin-top:25px}.account-card div{display:flex;gap:14px;margin-top:auto}.account-card a{color:var(--muted);font:600 8px var(--mono);text-decoration:none}.account-card a:first-child{color:var(--acid)}@media(max-width:950px){.hero{grid-template-columns:1fr}.signal-card{max-width:620px}.how-grid{grid-template-columns:1fr 1fr}}@media(max-width:620px){.alpha-tag{display:none}.hero{padding-top:50px}.hero h1{font-size:48px}.hero-actions{flex-direction:column}.trust-row{flex-wrap:wrap}.signal-card{min-height:470px}.how-grid{grid-template-columns:1fr}.how-grid article,.account-card{border-bottom:1px solid var(--line)}}
|
||||
</style>
|
||||
|
||||
@@ -28,11 +28,12 @@ onMounted(async () => {
|
||||
<div class="join noise">
|
||||
<AppMark />
|
||||
<div v-if="state === 'joining'" class="card"><small>PARTY INVITE</small><h1>JOINING CAMPAIGN…</h1><p>Verifying your seat at the table.</p></div>
|
||||
<div v-else-if="state === 'signin'" class="card"><small>PARTY INVITE</small><h1>ENTER THE ALPHA FIRST</h1><p>Your invite is saved. Guest access needs no email; after entering, this campaign will open automatically.</p><NuxtLink to="/">ENTER THE ALPHA →</NuxtLink></div>
|
||||
<div v-else-if="state === 'signin'" class="card"><small>PARTY INVITE</small><h1>ACCOUNT REQUIRED</h1><p>Your invite is saved. Sign in or create an email account, and this campaign will open automatically.</p><div class="auth-actions"><NuxtLink to="/auth/sign-in">SIGN IN →</NuxtLink><NuxtLink to="/auth/sign-up">CREATE ACCOUNT</NuxtLink></div></div>
|
||||
<div v-else class="card"><small>INVITE ERROR</small><h1>THE DOOR STAYED SHUT</h1><p>{{ message }}</p><NuxtLink to="/dashboard">BACK TO DASHBOARD</NuxtLink></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.join{min-height:100vh;display:grid;place-content:center;justify-items:center;gap:30px;padding:24px}.card{width:min(560px,calc(100vw - 40px));padding:42px;border:1px solid var(--line);background:#0d0d0c}.card small{font:600 8px var(--mono);letter-spacing:.16em;color:var(--acid)}.card h1{font:600 clamp(26px,5vw,45px)/1.05 var(--display);letter-spacing:-.05em}.card p{color:var(--muted);line-height:1.7}.card a{display:inline-flex;margin-top:20px;padding:15px 18px;background:var(--acid);color:#080808;text-decoration:none;font:600 9px var(--mono)}
|
||||
.auth-actions{display:flex;gap:10px;flex-wrap:wrap}.auth-actions a:last-child{border:1px solid var(--line);background:transparent;color:var(--ink)}
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,11 @@ const AuthEmailSchema = z.preprocess(
|
||||
value => value === null || value === '' ? undefined : value,
|
||||
z.string().email().optional(),
|
||||
)
|
||||
const AuthUserSchema = z.object({ id: UuidSchema, email: AuthEmailSchema })
|
||||
const AuthUserSchema = z.object({
|
||||
id: UuidSchema,
|
||||
email: AuthEmailSchema,
|
||||
is_anonymous: z.boolean().optional().default(false),
|
||||
})
|
||||
|
||||
interface RequestOptions extends RequestInit {
|
||||
prefer?: string
|
||||
@@ -95,6 +99,9 @@ export async function requireStageTwoUser(event: H3Event): Promise<StageTwoUser>
|
||||
if (!response.ok) throw createError({ statusCode: 401, statusMessage: 'The access token is invalid or expired.' })
|
||||
const parsed = AuthUserSchema.safeParse(await response.json())
|
||||
if (!parsed.success) throw createError({ statusCode: 401, statusMessage: 'Supabase returned an invalid user.' })
|
||||
if (parsed.data.is_anonymous || !parsed.data.email) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'A verified email account is required.' })
|
||||
}
|
||||
|
||||
const profiles = await stageTwoDatabase<Array<{ id: string }>>(`profiles?select=id&id=eq.${parsed.data.id}&limit=1`)
|
||||
if (!profiles[0]) throw createError({ statusCode: 403, statusMessage: 'This account is not enabled for the alpha.' })
|
||||
|
||||
@@ -38,14 +38,30 @@ async function jsonRequest(url, options = {}) {
|
||||
return payload
|
||||
}
|
||||
|
||||
async function createGuest(label) {
|
||||
const session = await jsonRequest(`${supabaseUrl}/auth/v1/signup`, {
|
||||
function serviceHeaders() {
|
||||
return {
|
||||
apikey: serviceKey,
|
||||
...(serviceKey.split('.').length === 3 ? { Authorization: `Bearer ${serviceKey}` } : {}),
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
async function createEmailAccount(label) {
|
||||
const email = `smoke-${randomUUID()}@example.com`
|
||||
const password = `Smoke-${randomUUID()}!aA1`
|
||||
const user = await jsonRequest(`${supabaseUrl}/auth/v1/admin/users`, {
|
||||
method: 'POST',
|
||||
headers: serviceHeaders(),
|
||||
body: JSON.stringify({ email, password, email_confirm: true, user_metadata: { display_name: label } }),
|
||||
})
|
||||
const session = await jsonRequest(`${supabaseUrl}/auth/v1/token?grant_type=password`, {
|
||||
method: 'POST',
|
||||
headers: { apikey: anonKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: { display_name: label }, gotrue_meta_security: {} }),
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
if (!session?.access_token || !session?.user?.id) throw new Error('[smoke] Supabase returned an incomplete anonymous session.')
|
||||
return { token: session.access_token, id: session.user.id }
|
||||
if (!session?.access_token || !session?.user?.id) throw new Error('[smoke] Supabase returned an incomplete email session.')
|
||||
if (session.user.id !== user.id) throw new Error('[smoke] Email login returned the wrong user.')
|
||||
return { token: session.access_token, id: session.user.id, email }
|
||||
}
|
||||
|
||||
function appRequest(path, token, options = {}) {
|
||||
@@ -56,15 +72,10 @@ function appRequest(path, token, options = {}) {
|
||||
}
|
||||
|
||||
async function servicePatch(path, body) {
|
||||
const serviceAuthorization = serviceKey.split('.').length === 3
|
||||
? { Authorization: `Bearer ${serviceKey}` }
|
||||
: {}
|
||||
await jsonRequest(`${supabaseUrl}/rest/v1/${path}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
apikey: serviceKey,
|
||||
...serviceAuthorization,
|
||||
'Content-Type': 'application/json',
|
||||
...serviceHeaders(),
|
||||
Prefer: 'return=representation',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
@@ -72,10 +83,10 @@ async function servicePatch(path, body) {
|
||||
}
|
||||
|
||||
const suffix = Date.now().toString(36).toUpperCase()
|
||||
console.log('[smoke] Creating two isolated guest sessions…')
|
||||
console.log('[smoke] Creating two confirmed email accounts…')
|
||||
const [owner, player] = await Promise.all([
|
||||
createGuest(`Smoke Owner ${suffix}`),
|
||||
createGuest(`Smoke Player ${suffix}`),
|
||||
createEmailAccount(`Smoke Owner ${suffix}`),
|
||||
createEmailAccount(`Smoke Player ${suffix}`),
|
||||
])
|
||||
|
||||
console.log('[smoke] Creating a private world and campaign through the public API…')
|
||||
|
||||
@@ -2175,6 +2175,89 @@ grant execute on function public.dng_schema_version() to service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
|
||||
-- ============================================================================
|
||||
-- 0008_required_email_accounts.sql
|
||||
-- ============================================================================
|
||||
|
||||
-- D&G now uses recoverable email/password accounts. Keep the legacy function
|
||||
-- names so existing auth.users triggers are upgraded in place.
|
||||
|
||||
create or replace function public.create_profile_for_allowlisted_user()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_display_name text;
|
||||
begin
|
||||
if coalesce(new.is_anonymous, false) or nullif(btrim(new.email), '') is null then
|
||||
raise exception using
|
||||
errcode = '42501',
|
||||
message = 'A Dungeons & Ground account requires an email address.';
|
||||
end if;
|
||||
|
||||
v_display_name := coalesce(
|
||||
nullif(btrim(new.raw_user_meta_data ->> 'display_name'), ''),
|
||||
split_part(new.email, '@', 1),
|
||||
'Adventurer'
|
||||
);
|
||||
|
||||
insert into public.profiles(id, display_name)
|
||||
values (new.id, left(v_display_name, 80))
|
||||
on conflict (id) do update
|
||||
set display_name = excluded.display_name;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.create_profile_for_allowlisted_user() from public, anon, authenticated;
|
||||
|
||||
create or replace function public.enforce_alpha_email_allowlist()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
if coalesce(new.is_anonymous, false) or nullif(btrim(new.email), '') is null then
|
||||
raise exception using
|
||||
errcode = '42501',
|
||||
message = 'A Dungeons & Ground account requires an email address.';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.enforce_alpha_email_allowlist() from public, anon, authenticated;
|
||||
|
||||
-- Installations may contain confirmed email users created before the D&G
|
||||
-- schema. Give every non-anonymous email account a profile without touching
|
||||
-- existing campaign ownership or display names.
|
||||
insert into public.profiles(id, display_name)
|
||||
select
|
||||
auth_user.id,
|
||||
left(coalesce(
|
||||
nullif(btrim(auth_user.raw_user_meta_data ->> 'display_name'), ''),
|
||||
split_part(auth_user.email, '@', 1),
|
||||
'Adventurer'
|
||||
), 80)
|
||||
from auth.users auth_user
|
||||
where not coalesce(auth_user.is_anonymous, false)
|
||||
and nullif(btrim(auth_user.email), '') is not null
|
||||
on conflict (id) do nothing;
|
||||
|
||||
create or replace function public.dng_schema_version()
|
||||
returns integer language sql stable security definer set search_path = '' as $$
|
||||
select 8;
|
||||
$$;
|
||||
|
||||
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||
grant execute on function public.dng_schema_version() to service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
|
||||
|
||||
do $$
|
||||
begin
|
||||
@@ -2199,7 +2282,7 @@ begin
|
||||
if to_regprocedure('public.stage_four_create_character(uuid,uuid,text,text,text,jsonb,integer,integer,integer,integer,jsonb,jsonb,jsonb)') is null then
|
||||
raise exception 'D&G bootstrap verification failed: character creation RPC is missing';
|
||||
end if;
|
||||
if public.dng_schema_version() <> 7 then
|
||||
if public.dng_schema_version() <> 8 then
|
||||
raise exception 'D&G bootstrap verification failed: unexpected schema version';
|
||||
end if;
|
||||
end;
|
||||
|
||||
78
supabase/migrations/0008_required_email_accounts.sql
Normal file
78
supabase/migrations/0008_required_email_accounts.sql
Normal file
@@ -0,0 +1,78 @@
|
||||
-- D&G now uses recoverable email/password accounts. Keep the legacy function
|
||||
-- names so existing auth.users triggers are upgraded in place.
|
||||
|
||||
create or replace function public.create_profile_for_allowlisted_user()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_display_name text;
|
||||
begin
|
||||
if coalesce(new.is_anonymous, false) or nullif(btrim(new.email), '') is null then
|
||||
raise exception using
|
||||
errcode = '42501',
|
||||
message = 'A Dungeons & Ground account requires an email address.';
|
||||
end if;
|
||||
|
||||
v_display_name := coalesce(
|
||||
nullif(btrim(new.raw_user_meta_data ->> 'display_name'), ''),
|
||||
split_part(new.email, '@', 1),
|
||||
'Adventurer'
|
||||
);
|
||||
|
||||
insert into public.profiles(id, display_name)
|
||||
values (new.id, left(v_display_name, 80))
|
||||
on conflict (id) do update
|
||||
set display_name = excluded.display_name;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.create_profile_for_allowlisted_user() from public, anon, authenticated;
|
||||
|
||||
create or replace function public.enforce_alpha_email_allowlist()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
begin
|
||||
if coalesce(new.is_anonymous, false) or nullif(btrim(new.email), '') is null then
|
||||
raise exception using
|
||||
errcode = '42501',
|
||||
message = 'A Dungeons & Ground account requires an email address.';
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.enforce_alpha_email_allowlist() from public, anon, authenticated;
|
||||
|
||||
-- Installations may contain confirmed email users created before the D&G
|
||||
-- schema. Give every non-anonymous email account a profile without touching
|
||||
-- existing campaign ownership or display names.
|
||||
insert into public.profiles(id, display_name)
|
||||
select
|
||||
auth_user.id,
|
||||
left(coalesce(
|
||||
nullif(btrim(auth_user.raw_user_meta_data ->> 'display_name'), ''),
|
||||
split_part(auth_user.email, '@', 1),
|
||||
'Adventurer'
|
||||
), 80)
|
||||
from auth.users auth_user
|
||||
where not coalesce(auth_user.is_anonymous, false)
|
||||
and nullif(btrim(auth_user.email), '') is not null
|
||||
on conflict (id) do nothing;
|
||||
|
||||
create or replace function public.dng_schema_version()
|
||||
returns integer language sql stable security definer set search_path = '' as $$
|
||||
select 8;
|
||||
$$;
|
||||
|
||||
revoke all on function public.dng_schema_version() from public, anon, authenticated;
|
||||
grant execute on function public.dng_schema_version() to service_role;
|
||||
|
||||
notify pgrst, 'reload schema';
|
||||
Reference in New Issue
Block a user