feat: add first-run admin bootstrap flow and site-admin badge
This commit is contained in:
@@ -33,6 +33,10 @@ STRIPE_BILLING_PORTAL_CONFIGURATION_ID="bpc_CHANGE_ME"
|
|||||||
ADMIN_EMAILS="ops@example.com"
|
ADMIN_EMAILS="ops@example.com"
|
||||||
BILLING_ADMIN_EMAILS="ops@example.com" # Deprecated fallback; use ADMIN_EMAILS
|
BILLING_ADMIN_EMAILS="ops@example.com" # Deprecated fallback; use ADMIN_EMAILS
|
||||||
|
|
||||||
|
# Admin bootstraping
|
||||||
|
ALLOW_ADMIN_BOOTSTRAP="true" # Disable after first admin is created
|
||||||
|
ADMIN_BOOTSTRAP_TOKEN="CHANGE_ME_BOOTSTRAP_TOKEN"
|
||||||
|
|
||||||
# Docker Compose database env vars
|
# Docker Compose database env vars
|
||||||
POSTGRES_DB="leads4less"
|
POSTGRES_DB="leads4less"
|
||||||
POSTGRES_USER="postgres"
|
POSTGRES_USER="postgres"
|
||||||
|
|||||||
@@ -9,12 +9,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
### Added
|
### Added
|
||||||
- Added migrations to enforce workspace membership roles as `owner`/`member` only and to introduce DB-backed application-admin identities with access-audit storage.
|
- Added migrations to enforce workspace membership roles as `owner`/`member` only and to introduce DB-backed application-admin identities with access-audit storage.
|
||||||
- Added centralized admin authorization and audit helpers so internal `/admin/*` routes can use one shared access check and log admin support activity.
|
- Added centralized admin authorization and audit helpers so internal `/admin/*` routes can use one shared access check and log admin support activity.
|
||||||
|
- Added a first-run admin bootstrap flow with `/api/admin/bootstrap/status` and `/api/admin/bootstrap/claim` so the initial application-admin account can be claimed safely.
|
||||||
|
- Added `bootstrap-token` and `bootstrap-enabled` environment/config support, plus setup docs and operational checklist updates for first-run admin provisioning.
|
||||||
|
- Added authenticated UI admin-badge visibility by exposing `isAdmin` on shared session/auth payloads.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Replaced env-only billing-admin authorization with application-admin checks backed by database records, while keeping env allowlist fallback support for rollout safety.
|
- Replaced env-only billing-admin authorization with application-admin checks backed by database records, while keeping env allowlist fallback support for rollout safety.
|
||||||
- Updated account and workspace permission handling so only workspace owners can manage workspace settings, and admin tooling visibility is driven by the new app-admin identity.
|
- Updated account and workspace permission handling so only workspace owners can manage workspace settings, and admin tooling visibility is driven by the new app-admin identity.
|
||||||
- Updated environment and setup docs for Stripe keys plus the new preferred `ADMIN_EMAILS` allowlist variable (with `BILLING_ADMIN_EMAILS` retained as a deprecated fallback).
|
- Updated environment and setup docs for Stripe keys plus the new preferred `ADMIN_EMAILS` allowlist variable (with `BILLING_ADMIN_EMAILS` retained as a deprecated fallback).
|
||||||
- Reorganized the pricing rollout tracker to reflect completed phases, deferred work, and the new app-admin and workspace-role migration milestones.
|
- Reorganized the pricing rollout tracker to reflect completed phases, deferred work, and the new app-admin and workspace-role migration milestones.
|
||||||
|
- Updated auth/session responses to include canonical admin-status checks so admin UI state stays consistent after refresh and login.
|
||||||
|
- Updated README and TODO planning docs for phased admin-console rollout and the first-run operational checklist.
|
||||||
|
|
||||||
## [2026-05-22]
|
## [2026-05-22]
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,36 @@ If you open the app from another machine on your LAN, set `VITE_API_BASE_URL` an
|
|||||||
4. Start the worker:
|
4. Start the worker:
|
||||||
`npm run dev:worker`
|
`npm run dev:worker`
|
||||||
|
|
||||||
|
## First Run Checklist
|
||||||
|
|
||||||
|
- [ ] Install dependencies: `npm install`
|
||||||
|
- [ ] Copy `.env.example` to `.env.local` and fill required keys: `DATABASE_URL`, `COOKIE_SECRET`, `GOOGLE_MAPS_SERVER_KEY`, `VITE_GOOGLE_MAPS_PLATFORM_KEY`, and Stripe keys if testing billing
|
||||||
|
- [ ] Run migrations: `npm run migrate`
|
||||||
|
- [ ] Set `ALLOW_ADMIN_BOOTSTRAP=true` and define `ADMIN_BOOTSTRAP_TOKEN`
|
||||||
|
- [ ] Start web, API, and worker: `npm run dev:web`, `npm run dev:api`, `npm run dev:worker`
|
||||||
|
- [ ] Visit `/auth` and create the first site admin
|
||||||
|
- [ ] Disable bootstrap after first admin creation: `ALLOW_ADMIN_BOOTSTRAP=false`
|
||||||
|
- [ ] Verify admin billing access at `/api/admin/billing/workspaces`
|
||||||
|
|
||||||
|
## First-run Admin Bootstrap
|
||||||
|
|
||||||
|
Bootstrap mode is only needed when no active application admin exists.
|
||||||
|
|
||||||
|
- The DB-backed `application_admins` table is the primary source of truth for app-admin access.
|
||||||
|
- `ADMIN_EMAILS` and `BILLING_ADMIN_EMAILS` are fallback allowlists during rollout.
|
||||||
|
|
||||||
|
1. Run migrations first: `npm run migrate`
|
||||||
|
2. Set `ALLOW_ADMIN_BOOTSTRAP=true` and `ADMIN_BOOTSTRAP_TOKEN` in your env file
|
||||||
|
3. Visit `/auth`, then create the first account in "Create first site admin" mode
|
||||||
|
4. After the first admin is created, set `ALLOW_ADMIN_BOOTSTRAP=false`
|
||||||
|
|
||||||
## Stripe Billing Setup
|
## Stripe Billing Setup
|
||||||
|
|
||||||
Stripe is now the active payments integration for self-serve subscriptions and one-time export packs.
|
Stripe is now the active payments integration for self-serve subscriptions and one-time export packs.
|
||||||
|
|
||||||
Configure these server-side env vars to enable billing routes:
|
Configure these server-side env vars to enable billing routes:
|
||||||
|
|
||||||
|
- `STRIPE_PUBLISHABLE_KEY`
|
||||||
- `STRIPE_SECRET_KEY`
|
- `STRIPE_SECRET_KEY`
|
||||||
- `STRIPE_WEBHOOK_SECRET`
|
- `STRIPE_WEBHOOK_SECRET`
|
||||||
- `STRIPE_PRICE_STARTER_MONTHLY`
|
- `STRIPE_PRICE_STARTER_MONTHLY`
|
||||||
|
|||||||
@@ -87,6 +87,22 @@
|
|||||||
- entitlement/usage treatment when the target plan is below current usage
|
- entitlement/usage treatment when the target plan is below current usage
|
||||||
- account messaging for pending downgrade state
|
- account messaging for pending downgrade state
|
||||||
|
|
||||||
|
### Admin Dashboard / Console Incremental Plan
|
||||||
|
- [ ] Phase A (Read-only Admin Console foundation): Create dedicated admin page(s) separate from Account page and gate all access to app-admin users only.
|
||||||
|
- [ ] Phase A (Read-only Admin Console foundation): Add admin navigation entry/tab visible only to app-admin users.
|
||||||
|
- [ ] Phase A (Read-only Admin Console foundation): Move existing admin billing visibility tools (workspace search + workspace detail) from Account page to admin page.
|
||||||
|
- [ ] Phase A (Read-only Admin Console foundation): Add admin analytics summary panel on admin page powered by `/admin/analytics/summary`.
|
||||||
|
- [ ] Phase A (Read-only Admin Console foundation): Keep server-side `requireAdmin` as the source of truth (UI checks are convenience only).
|
||||||
|
- [ ] Phase B (Admin Access Management): Add admin-only APIs for listing, adding, disabling, and re-enabling application admins.
|
||||||
|
- [ ] Phase B (Admin Access Management): Add admin UI for managing app-admin identities with status visibility (active/disabled).
|
||||||
|
- [ ] Phase B (Admin Access Management): Prevent accidental lockout with guardrails (e.g., disallow disabling the last active admin).
|
||||||
|
- [ ] Phase B (Admin Access Management): Add explicit audit entries for admin identity mutations.
|
||||||
|
- [ ] Phase C (Audit & Support Operations): Add admin audit log page/table with filters (actor, action, workspace, date window).
|
||||||
|
- [ ] Phase C (Audit & Support Operations): Expose bootstrap/security posture checks in admin UI (bootstrap enabled state, fallback allowlist usage warnings).
|
||||||
|
- [ ] Phase C (Audit & Support Operations): Add support-oriented diagnostics widgets (recent webhook issues, billing sync errors, timeline anomalies).
|
||||||
|
- [ ] Phase D (Safe Mutations, later): Keep initial admin console read-only for billing data; defer write/mutation actions until policies and runbooks are defined.
|
||||||
|
- [ ] Phase D (Safe Mutations, later): For future write actions, require explicit confirmations, actor attribution, and rollback guidance.
|
||||||
|
|
||||||
## 12) [DEFER] Operational Enforcement Follow-Up
|
## 12) [DEFER] Operational Enforcement Follow-Up
|
||||||
- [ ] Add queue prioritization by plan tier.
|
- [ ] Add queue prioritization by plan tier.
|
||||||
- [ ] Add throttling/fair-usage controls.
|
- [ ] Add throttling/fair-usage controls.
|
||||||
@@ -116,6 +132,10 @@
|
|||||||
- [ ] Phase 5: finalize add-on strategy before wiring payment products.
|
- [ ] Phase 5: finalize add-on strategy before wiring payment products.
|
||||||
- [ ] Phase 6: integrate payments and subscription lifecycle handling.
|
- [ ] Phase 6: integrate payments and subscription lifecycle handling.
|
||||||
- [ ] Phase 7: harden post-payments lifecycle handling, wire real billing CTAs, and add pragmatic admin billing visibility before broader commercialization work.
|
- [ ] Phase 7: harden post-payments lifecycle handling, wire real billing CTAs, and add pragmatic admin billing visibility before broader commercialization work.
|
||||||
|
- [ ] Phase 7a: ship dedicated read-only admin console and migrate existing admin billing tools out of the account page.
|
||||||
|
- [ ] Phase 7b: ship app-admin identity management APIs/UI with last-admin lockout protection and audit logging.
|
||||||
|
- [ ] Phase 7c: ship admin audit explorer and support diagnostics views.
|
||||||
|
- [ ] Phase 7d: evaluate controlled admin write-actions only after policy/runbook readiness.
|
||||||
- [ ] Phase 8: expand analytics, ops, and revenue instrumentation around the live billing and upgrade flows.
|
- [ ] Phase 8: expand analytics, ops, and revenue instrumentation around the live billing and upgrade flows.
|
||||||
- [ ] Phase 9: launch collaboration, API, enrichment, and enterprise features as architecture matures.
|
- [ ] Phase 9: launch collaboration, API, enrichment, and enterprise features as architecture matures.
|
||||||
- [ ] Phase 10: complete deferred operational enforcement work such as queue prioritization, throttling, and backend export enforcement when runtime scale justifies it.
|
- [ ] Phase 10: complete deferred operational enforcement work such as queue prioritization, throttling, and backend export enforcement when runtime scale justifies it.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { billingRoutes } from './routes/billing.js';
|
|||||||
import { healthRoutes } from './routes/health.js';
|
import { healthRoutes } from './routes/health.js';
|
||||||
import { searchJobRoutes } from './routes/search-jobs.js';
|
import { searchJobRoutes } from './routes/search-jobs.js';
|
||||||
import { analyticsRoutes } from './routes/analytics.js';
|
import { analyticsRoutes } from './routes/analytics.js';
|
||||||
|
import { adminBootstrapRoutes } from './routes/admin-bootstrap.js';
|
||||||
|
|
||||||
function parseAllowedOrigins(rawOrigins: string) {
|
function parseAllowedOrigins(rawOrigins: string) {
|
||||||
return rawOrigins
|
return rawOrigins
|
||||||
@@ -56,6 +57,7 @@ export async function buildApp() {
|
|||||||
await app.register(searchJobRoutes, { prefix: '/api' });
|
await app.register(searchJobRoutes, { prefix: '/api' });
|
||||||
await app.register(deepResearchRoutes, { prefix: '/api' });
|
await app.register(deepResearchRoutes, { prefix: '/api' });
|
||||||
await app.register(analyticsRoutes, { prefix: '/api' });
|
await app.register(analyticsRoutes, { prefix: '/api' });
|
||||||
|
await app.register(adminBootstrapRoutes, { prefix: '/api' });
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,42 @@ export async function isApplicationAdmin(db: DbClient, email: string) {
|
|||||||
return isAdminEmailAllowlisted(email);
|
return isAdminEmailAllowlisted(email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getActiveApplicationAdminCount(db: DbClient): Promise<number> {
|
||||||
|
const result = await db.query<{ count: string }>(
|
||||||
|
`
|
||||||
|
select count(*)::text as count
|
||||||
|
from public.application_admins
|
||||||
|
where status = 'active'
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Number(result.rows[0]?.count ?? '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isBootstrapRequired(db: DbClient): Promise<boolean> {
|
||||||
|
return (await getActiveApplicationAdminCount(db)) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createApplicationAdmin(
|
||||||
|
db: DbClient,
|
||||||
|
input: { email: string; createdByUserId?: string | null; permissionsJson?: unknown[] },
|
||||||
|
): Promise<void> {
|
||||||
|
const normalizedEmail = normalizeEmail(input.email);
|
||||||
|
|
||||||
|
await db.query(
|
||||||
|
`
|
||||||
|
insert into public.application_admins (email, email_normalized, status, permissions_json, created_by_user_id)
|
||||||
|
values ($1, $2, 'active', $3::jsonb, $4)
|
||||||
|
on conflict (email_normalized)
|
||||||
|
do update set
|
||||||
|
email = excluded.email,
|
||||||
|
status = 'active',
|
||||||
|
updated_at = now()
|
||||||
|
`,
|
||||||
|
[input.email.trim(), normalizedEmail, JSON.stringify(input.permissionsJson ?? []), input.createdByUserId ?? null],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply) {
|
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply) {
|
||||||
const user = request.authUser;
|
const user = request.authUser;
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type SessionRow = {
|
|||||||
avatar_url: string | null;
|
avatar_url: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
is_admin: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function hashSessionToken(token: string) {
|
function hashSessionToken(token: string) {
|
||||||
@@ -37,6 +38,7 @@ function mapSessionRow(row: SessionRow): SessionUser {
|
|||||||
avatarUrl: row.avatar_url,
|
avatarUrl: row.avatar_url,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
|
isAdmin: row.is_admin,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,9 +105,20 @@ export async function getSessionUserByToken(db: DbClient, token: string) {
|
|||||||
const tokenHash = hashSessionToken(token);
|
const tokenHash = hashSessionToken(token);
|
||||||
const result = await db.query<SessionRow>(
|
const result = await db.query<SessionRow>(
|
||||||
`
|
`
|
||||||
select s.id as session_id, u.id, u.email, u.display_name, u.avatar_url, u.created_at, u.updated_at
|
select
|
||||||
|
s.id as session_id,
|
||||||
|
u.id,
|
||||||
|
u.email,
|
||||||
|
u.display_name,
|
||||||
|
u.avatar_url,
|
||||||
|
u.created_at,
|
||||||
|
u.updated_at,
|
||||||
|
(aa.id is not null) as is_admin
|
||||||
from public.sessions s
|
from public.sessions s
|
||||||
join public.users u on u.id = s.user_id
|
join public.users u on u.id = s.user_id
|
||||||
|
left join public.application_admins aa
|
||||||
|
on aa.email_normalized = lower(trim(u.email))
|
||||||
|
and aa.status = 'active'
|
||||||
where s.token_hash = $1 and s.expires_at > now()
|
where s.token_hash = $1 and s.expires_at > now()
|
||||||
limit 1
|
limit 1
|
||||||
`,
|
`,
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ const envSchema = z.object({
|
|||||||
STRIPE_BILLING_PORTAL_CONFIGURATION_ID: z.string().optional(),
|
STRIPE_BILLING_PORTAL_CONFIGURATION_ID: z.string().optional(),
|
||||||
ADMIN_EMAILS: z.string().optional(),
|
ADMIN_EMAILS: z.string().optional(),
|
||||||
BILLING_ADMIN_EMAILS: z.string().optional(),
|
BILLING_ADMIN_EMAILS: z.string().optional(),
|
||||||
|
ALLOW_ADMIN_BOOTSTRAP: z.coerce.boolean().default(false),
|
||||||
|
ADMIN_BOOTSTRAP_TOKEN: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppEnv = z.infer<typeof envSchema>;
|
export type AppEnv = z.infer<typeof envSchema>;
|
||||||
@@ -80,3 +82,7 @@ export function isBillingAdminEmail(email: string) {
|
|||||||
|
|
||||||
return allowlist.includes(email.trim().toLowerCase());
|
return allowlist.includes(email.trim().toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isAdminBootstrapEnabled() {
|
||||||
|
return getEnv().ALLOW_ADMIN_BOOTSTRAP;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
|
||||||
|
import { ZodError, z } from 'zod';
|
||||||
|
import type { AdminBootstrapClaimResponse, AdminBootstrapStatusResponse } from '../../../shared/types.js';
|
||||||
|
import { createDefaultWorkspaceForUser } from '../account/repository.js';
|
||||||
|
import { createApplicationAdmin, isApplicationAdmin, isBootstrapRequired, recordAdminAccessAudit } from '../auth/admin.js';
|
||||||
|
import { hashPassword } from '../auth/passwords.js';
|
||||||
|
import { createSession, setSessionCookie } from '../auth/sessions.js';
|
||||||
|
import { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
|
||||||
|
import { getEnv, isAdminBootstrapEnabled } from '../config/env.js';
|
||||||
|
import { getDbPool } from '../db/pool.js';
|
||||||
|
|
||||||
|
const claimSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(6),
|
||||||
|
displayName: z.string().trim().min(1).max(120).optional(),
|
||||||
|
bootstrapToken: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
class HttpError extends Error {
|
||||||
|
statusCode: number;
|
||||||
|
|
||||||
|
constructor(statusCode: number, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestMetadata(request: FastifyRequest) {
|
||||||
|
return {
|
||||||
|
userAgent: request.headers['user-agent'],
|
||||||
|
ipAddress: request.ip || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const adminBootstrapRoutes: FastifyPluginAsync = async (app) => {
|
||||||
|
app.get('/admin/bootstrap/status', async (_request, reply) => {
|
||||||
|
const db = getDbPool();
|
||||||
|
const response: AdminBootstrapStatusResponse = {
|
||||||
|
bootstrapRequired: await isBootstrapRequired(db),
|
||||||
|
bootstrapEnabled: isAdminBootstrapEnabled(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return reply.send(response);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/admin/bootstrap/claim', async (request, reply) => {
|
||||||
|
try {
|
||||||
|
const payload = claimSchema.parse(request.body);
|
||||||
|
const env = getEnv();
|
||||||
|
|
||||||
|
if (!isAdminBootstrapEnabled()) {
|
||||||
|
return reply.code(403).send({ error: 'Admin bootstrap is disabled.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!env.ADMIN_BOOTSTRAP_TOKEN || payload.bootstrapToken !== env.ADMIN_BOOTSTRAP_TOKEN) {
|
||||||
|
return reply.code(403).send({ error: 'Invalid bootstrap token.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDbPool();
|
||||||
|
const client = await db.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query('begin');
|
||||||
|
await client.query('lock table public.application_admins in share row exclusive mode');
|
||||||
|
|
||||||
|
if (!(await isBootstrapRequired(client))) {
|
||||||
|
throw new HttpError(409, 'Admin bootstrap is no longer required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingUser = await getUserByEmail(client, payload.email);
|
||||||
|
if (existingUser) {
|
||||||
|
throw new HttpError(409, 'An account with that email already exists.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hashPassword(payload.password);
|
||||||
|
const user = await createUser(client, {
|
||||||
|
email: payload.email,
|
||||||
|
passwordHash,
|
||||||
|
displayName: payload.displayName,
|
||||||
|
});
|
||||||
|
|
||||||
|
await createDefaultWorkspaceForUser(client, user);
|
||||||
|
await createApplicationAdmin(client, {
|
||||||
|
email: user.email,
|
||||||
|
createdByUserId: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const session = await createSession(client, user.id, getRequestMetadata(request));
|
||||||
|
|
||||||
|
await recordAdminAccessAudit(client, {
|
||||||
|
actorUserId: user.id,
|
||||||
|
actorEmail: user.email,
|
||||||
|
route: '/api/admin/bootstrap/claim',
|
||||||
|
action: 'bootstrap_admin_claimed',
|
||||||
|
metadataJson: {
|
||||||
|
bootstrapRequiredAtClaim: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.query('commit');
|
||||||
|
setSessionCookie(reply, session.token, session.expiresAt);
|
||||||
|
|
||||||
|
const response: AdminBootstrapClaimResponse = {
|
||||||
|
user: { ...toAppUser(user), sessionId: session.sessionId, isAdmin: await isApplicationAdmin(client, user.email) },
|
||||||
|
};
|
||||||
|
|
||||||
|
return reply.code(201).send(response);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('rollback');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid bootstrap claim payload.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof HttpError) {
|
||||||
|
return reply.code(error.statusCode).send({ error: error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((error as { code?: string }).code === '23505') {
|
||||||
|
return reply.code(409).send({ error: 'An account with that email already exists.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.error(error);
|
||||||
|
return reply.code(500).send({ error: 'Failed to claim admin bootstrap.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import type { FastifyPluginAsync, FastifyRequest } from 'fastify';
|
|||||||
import { ZodError, z } from 'zod';
|
import { ZodError, z } from 'zod';
|
||||||
import { hashPassword, verifyPassword } from '../auth/passwords.js';
|
import { hashPassword, verifyPassword } from '../auth/passwords.js';
|
||||||
import { clearSessionCookie, createSession, deleteSessionById, deleteSessionByToken, getSessionTokenFromRequest, getSessionUserByToken, setSessionCookie, } from '../auth/sessions.js';
|
import { clearSessionCookie, createSession, deleteSessionById, deleteSessionByToken, getSessionTokenFromRequest, getSessionUserByToken, setSessionCookie, } from '../auth/sessions.js';
|
||||||
|
import { isApplicationAdmin } from '../auth/admin.js';
|
||||||
import { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
|
import { createUser, getUserByEmail, toAppUser } from '../auth/users.js';
|
||||||
import { createDefaultWorkspaceForUser } from '../account/repository.js';
|
import { createDefaultWorkspaceForUser } from '../account/repository.js';
|
||||||
import { getDbPool } from '../db/pool.js';
|
import { getDbPool } from '../db/pool.js';
|
||||||
@@ -36,8 +37,15 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return { user: null };
|
return { user: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getSessionUserByToken(getDbPool(), token);
|
const db = getDbPool();
|
||||||
return { user };
|
const user = await getSessionUserByToken(db, token);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return { user: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = await isApplicationAdmin(db, user.email);
|
||||||
|
return { user: { ...user, isAdmin } };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/auth/signup', async (request, reply) => {
|
app.post('/auth/signup', async (request, reply) => {
|
||||||
@@ -73,8 +81,9 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSessionCookie(reply, session.token, session.expiresAt);
|
setSessionCookie(reply, session.token, session.expiresAt);
|
||||||
|
const isAdmin = await isApplicationAdmin(db, user.email);
|
||||||
|
|
||||||
return reply.code(201).send({ user: { ...toAppUser(user), sessionId: session.sessionId } });
|
return reply.code(201).send({ user: { ...toAppUser(user), sessionId: session.sessionId, isAdmin } });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid signup payload.' });
|
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid signup payload.' });
|
||||||
@@ -106,8 +115,9 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
|
|
||||||
const session = await createSession(db, user.id, getRequestMetadata(request));
|
const session = await createSession(db, user.id, getRequestMetadata(request));
|
||||||
setSessionCookie(reply, session.token, session.expiresAt);
|
setSessionCookie(reply, session.token, session.expiresAt);
|
||||||
|
const isAdmin = await isApplicationAdmin(db, user.email);
|
||||||
|
|
||||||
return { user: { ...toAppUser(user), sessionId: session.sessionId } };
|
return { user: { ...toAppUser(user), sessionId: session.sessionId, isAdmin } };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ZodError) {
|
if (error instanceof ZodError) {
|
||||||
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid login payload.' });
|
return reply.code(400).send({ error: error.issues[0]?.message || 'Invalid login payload.' });
|
||||||
|
|||||||
@@ -15,6 +15,23 @@ export interface AppUser {
|
|||||||
|
|
||||||
export interface SessionUser extends AppUser {
|
export interface SessionUser extends AppUser {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
isAdmin?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminBootstrapStatusResponse {
|
||||||
|
bootstrapRequired: boolean;
|
||||||
|
bootstrapEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminBootstrapClaimRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
displayName?: string;
|
||||||
|
bootstrapToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminBootstrapClaimResponse {
|
||||||
|
user: SessionUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkspaceType = 'personal' | 'company';
|
export type WorkspaceType = 'personal' | 'company';
|
||||||
|
|||||||
+127
-8
@@ -25,8 +25,15 @@ import { ResearchWorkspace } from './components/ResearchWorkspace';
|
|||||||
import { ResultsWorkspace } from './components/ResultsWorkspace';
|
import { ResultsWorkspace } from './components/ResultsWorkspace';
|
||||||
import { Alert, Badge, Button, Card, FieldLabel, Input, Surface } from './components/ui';
|
import { Alert, Badge, Button, Card, FieldLabel, Input, Surface } from './components/ui';
|
||||||
import type { BillingInterval, PlanCode } from '../shared/billing/plans';
|
import type { BillingInterval, PlanCode } from '../shared/billing/plans';
|
||||||
import type { SessionUser } from '../shared/types';
|
import type { AdminBootstrapStatusResponse, SessionUser } from '../shared/types';
|
||||||
import { getLocalSessionUser, signInWithLocalAuth, signOutWithLocalAuth, signUpWithLocalAuth } from './lib/auth';
|
import {
|
||||||
|
claimAdminBootstrap,
|
||||||
|
getAdminBootstrapStatus,
|
||||||
|
getLocalSessionUser,
|
||||||
|
signInWithLocalAuth,
|
||||||
|
signOutWithLocalAuth,
|
||||||
|
signUpWithLocalAuth,
|
||||||
|
} from './lib/auth';
|
||||||
import { hasApiConfig } from './lib/api';
|
import { hasApiConfig } from './lib/api';
|
||||||
|
|
||||||
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_PLATFORM_KEY ?? '';
|
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_PLATFORM_KEY ?? '';
|
||||||
@@ -46,6 +53,9 @@ export default function App() {
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [displayName, setDisplayName] = useState('');
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [bootstrapToken, setBootstrapToken] = useState('');
|
||||||
|
const [bootstrapStatus, setBootstrapStatus] = useState<AdminBootstrapStatusResponse | null>(null);
|
||||||
|
const [bootstrapLoading, setBootstrapLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
@@ -59,6 +69,45 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user || publicPage !== 'auth') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadBootstrapStatus = async () => {
|
||||||
|
setBootstrapLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await getAdminBootstrapStatus();
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBootstrapStatus(status);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthError(error instanceof Error ? error.message : 'Failed to load bootstrap status.');
|
||||||
|
} finally {
|
||||||
|
if (isMounted) {
|
||||||
|
setBootstrapLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadBootstrapStatus();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [publicPage, user]);
|
||||||
|
|
||||||
|
const isBootstrapRequired = bootstrapStatus?.bootstrapRequired === true;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
@@ -119,11 +168,29 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
|
if (isBootstrapRequired && bootstrapLoading) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
setAuthNotice(null);
|
setAuthNotice(null);
|
||||||
setIsAuthenticating(true);
|
setIsAuthenticating(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (isBootstrapRequired) {
|
||||||
|
const nextUser = await claimAdminBootstrap({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
displayName: displayName.trim() || undefined,
|
||||||
|
bootstrapToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
setUser(nextUser);
|
||||||
|
setAuthNotice('First site admin created and signed in.');
|
||||||
|
navigatePublicPage('landing', setPublicPage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (authMode === 'sign_up') {
|
if (authMode === 'sign_up') {
|
||||||
const nextUser = await signUpWithLocalAuth({
|
const nextUser = await signUpWithLocalAuth({
|
||||||
email,
|
email,
|
||||||
@@ -164,6 +231,8 @@ export default function App() {
|
|||||||
setSelectedJobIds([]);
|
setSelectedJobIds([]);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setActiveTab('setup');
|
setActiveTab('setup');
|
||||||
|
setBootstrapStatus(null);
|
||||||
|
setBootstrapToken('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await signOutWithLocalAuth(sessionId);
|
await signOutWithLocalAuth(sessionId);
|
||||||
@@ -207,13 +276,18 @@ export default function App() {
|
|||||||
if (publicPage === 'auth') {
|
if (publicPage === 'auth') {
|
||||||
return (
|
return (
|
||||||
<AuthPage
|
<AuthPage
|
||||||
authMode={authMode}
|
authMode={isBootstrapRequired ? 'sign_up' : authMode}
|
||||||
authError={authError}
|
authError={authError}
|
||||||
authNotice={authNotice}
|
authNotice={authNotice}
|
||||||
|
bootstrapLoading={bootstrapLoading}
|
||||||
|
bootstrapRequired={isBootstrapRequired}
|
||||||
|
bootstrapToken={bootstrapToken}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
email={email}
|
email={email}
|
||||||
isAuthenticating={isAuthenticating}
|
isAuthenticating={isAuthenticating}
|
||||||
|
bootstrapEnabled={bootstrapStatus?.bootstrapEnabled ?? false}
|
||||||
password={password}
|
password={password}
|
||||||
|
onBootstrapTokenChange={setBootstrapToken}
|
||||||
onDisplayNameChange={setDisplayName}
|
onDisplayNameChange={setDisplayName}
|
||||||
onEmailChange={setEmail}
|
onEmailChange={setEmail}
|
||||||
onPasswordChange={setPassword}
|
onPasswordChange={setPassword}
|
||||||
@@ -569,10 +643,15 @@ function AuthPage(props: {
|
|||||||
authMode: 'sign_in' | 'sign_up';
|
authMode: 'sign_in' | 'sign_up';
|
||||||
authError: string | null;
|
authError: string | null;
|
||||||
authNotice: string | null;
|
authNotice: string | null;
|
||||||
|
bootstrapEnabled: boolean;
|
||||||
|
bootstrapLoading: boolean;
|
||||||
|
bootstrapRequired: boolean;
|
||||||
|
bootstrapToken: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
email: string;
|
email: string;
|
||||||
isAuthenticating: boolean;
|
isAuthenticating: boolean;
|
||||||
password: string;
|
password: string;
|
||||||
|
onBootstrapTokenChange: (value: string) => void;
|
||||||
onDisplayNameChange: (value: string) => void;
|
onDisplayNameChange: (value: string) => void;
|
||||||
onEmailChange: (value: string) => void;
|
onEmailChange: (value: string) => void;
|
||||||
onPasswordChange: (value: string) => void;
|
onPasswordChange: (value: string) => void;
|
||||||
@@ -584,10 +663,15 @@ function AuthPage(props: {
|
|||||||
authMode,
|
authMode,
|
||||||
authError,
|
authError,
|
||||||
authNotice,
|
authNotice,
|
||||||
|
bootstrapEnabled,
|
||||||
|
bootstrapLoading,
|
||||||
|
bootstrapRequired,
|
||||||
|
bootstrapToken,
|
||||||
displayName,
|
displayName,
|
||||||
email,
|
email,
|
||||||
isAuthenticating,
|
isAuthenticating,
|
||||||
password,
|
password,
|
||||||
|
onBootstrapTokenChange,
|
||||||
onDisplayNameChange,
|
onDisplayNameChange,
|
||||||
onEmailChange,
|
onEmailChange,
|
||||||
onPasswordChange,
|
onPasswordChange,
|
||||||
@@ -623,12 +707,16 @@ function AuthPage(props: {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<Badge variant="primary" className="px-4 py-2 text-sm">
|
<Badge variant="primary" className="px-4 py-2 text-sm">
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" />
|
||||||
Secure access to your intelligence workspace
|
{bootstrapRequired ? 'First-run initialization mode' : 'Secure access to your intelligence workspace'}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<h1 className="max-w-3xl text-5xl font-bold tracking-tight text-stone-950 sm:text-6xl">
|
<h1 className="max-w-3xl text-5xl font-bold tracking-tight text-stone-950 sm:text-6xl">
|
||||||
{authMode === 'sign_up' ? 'Create your workspace and start researching local markets.' : 'Sign in and continue your market intelligence workflow.'}
|
{bootstrapRequired
|
||||||
|
? 'Create first site admin'
|
||||||
|
: authMode === 'sign_up'
|
||||||
|
? 'Create your workspace and start researching local markets.'
|
||||||
|
: 'Sign in and continue your market intelligence workflow.'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-2xl text-lg leading-8 text-stone-600 sm:text-xl">
|
<p className="max-w-2xl text-lg leading-8 text-stone-600 sm:text-xl">
|
||||||
Access research runs, deep research coverage, clean map review, and saved business history from one focused operating surface.
|
Access research runs, deep research coverage, clean map review, and saved business history from one focused operating surface.
|
||||||
@@ -657,7 +745,11 @@ function AuthPage(props: {
|
|||||||
{authMode === 'sign_up' ? 'Create account' : 'Sign in'}
|
{authMode === 'sign_up' ? 'Create account' : 'Sign in'}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-sm text-stone-600">
|
<p className="mt-2 text-sm text-stone-600">
|
||||||
{authMode === 'sign_up' ? 'Set up your account to start using LocaleScope.' : 'Use your account to continue where you left off.'}
|
{bootstrapRequired
|
||||||
|
? 'Bootstrap is required because no active app admin exists.'
|
||||||
|
: authMode === 'sign_up'
|
||||||
|
? 'Set up your account to start using LocaleScope.'
|
||||||
|
: 'Use your account to continue where you left off.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-2xl bg-stone-100 p-3 text-stone-900">
|
<div className="rounded-2xl bg-stone-100 p-3 text-stone-900">
|
||||||
@@ -665,6 +757,7 @@ function AuthPage(props: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!bootstrapRequired && (
|
||||||
<div className="grid grid-cols-2 gap-2 rounded-2xl bg-stone-100 p-1">
|
<div className="grid grid-cols-2 gap-2 rounded-2xl bg-stone-100 p-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -685,6 +778,13 @@ function AuthPage(props: {
|
|||||||
Sign Up
|
Sign Up
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{bootstrapRequired && !bootstrapEnabled && (
|
||||||
|
<Alert variant="error" title="Bootstrap Disabled" className="mt-5">
|
||||||
|
<p>Bootstrap is required, but ALLOW_ADMIN_BOOTSTRAP is disabled.</p>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{authError && (
|
{authError && (
|
||||||
<Alert variant="error" title="Authentication Error" className="mt-5">
|
<Alert variant="error" title="Authentication Error" className="mt-5">
|
||||||
@@ -713,6 +813,19 @@ function AuthPage(props: {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{bootstrapRequired && (
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Bootstrap token</FieldLabel>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={bootstrapToken}
|
||||||
|
onChange={(event) => onBootstrapTokenChange(event.target.value)}
|
||||||
|
placeholder="Enter ADMIN_BOOTSTRAP_TOKEN"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Email</FieldLabel>
|
<FieldLabel>Email</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
@@ -738,7 +851,7 @@ function AuthPage(props: {
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isAuthenticating}
|
disabled={isAuthenticating || (bootstrapRequired && bootstrapLoading)}
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full rounded-2xl bg-stone-900 hover:bg-stone-800"
|
className="w-full rounded-2xl bg-stone-900 hover:bg-stone-800"
|
||||||
>
|
>
|
||||||
@@ -749,7 +862,13 @@ function AuthPage(props: {
|
|||||||
) : (
|
) : (
|
||||||
<LogIn className="h-5 w-5" />
|
<LogIn className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
{isAuthenticating
|
{bootstrapRequired
|
||||||
|
? isAuthenticating
|
||||||
|
? 'Creating first admin...'
|
||||||
|
: bootstrapLoading
|
||||||
|
? 'Checking bootstrap status...'
|
||||||
|
: 'Create First Admin'
|
||||||
|
: isAuthenticating
|
||||||
? authMode === 'sign_up'
|
? authMode === 'sign_up'
|
||||||
? 'Creating account...'
|
? 'Creating account...'
|
||||||
: 'Signing in...'
|
: 'Signing in...'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, Files, UserRound } from 'lucide-react';
|
import { Search, LayoutDashboard, Map as MapIcon, LogOut, Briefcase, Files, UserRound } from 'lucide-react';
|
||||||
import type { AppUser } from '../../shared/types';
|
import type { SessionUser } from '../../shared/types';
|
||||||
import { getUserAvatarUrl, getUserDisplayName } from '../lib/auth';
|
import { getUserAvatarUrl, getUserDisplayName } from '../lib/auth';
|
||||||
import { cn } from '../lib/cn';
|
import { cn } from '../lib/cn';
|
||||||
import { Button } from './ui';
|
import { Button } from './ui';
|
||||||
@@ -8,7 +8,7 @@ import { Button } from './ui';
|
|||||||
export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account';
|
export type AppTab = 'setup' | 'results' | 'dashboard' | 'map' | 'account';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
user: AppUser;
|
user: SessionUser;
|
||||||
activeTab: AppTab;
|
activeTab: AppTab;
|
||||||
setActiveTab: (tab: AppTab) => void;
|
setActiveTab: (tab: AppTab) => void;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
@@ -88,6 +88,11 @@ export function Layout({ user, activeTab, setActiveTab, onLogout, children }: La
|
|||||||
referrerPolicy="no-referrer"
|
referrerPolicy="no-referrer"
|
||||||
/>
|
/>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
{user.isAdmin ? (
|
||||||
|
<span className="mb-1 inline-flex items-center rounded-full border border-stone-300 bg-white px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-stone-700">
|
||||||
|
Site Admin
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<p className="truncate text-sm font-semibold text-stone-900">{userDisplayName}</p>
|
<p className="truncate text-sm font-semibold text-stone-900">{userDisplayName}</p>
|
||||||
<p className="truncate text-xs text-stone-500">{user.email}</p>
|
<p className="truncate text-xs text-stone-500">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+20
-1
@@ -1,4 +1,10 @@
|
|||||||
import type { AppUser, SessionUser } from '../../shared/types';
|
import type {
|
||||||
|
AdminBootstrapClaimRequest,
|
||||||
|
AdminBootstrapClaimResponse,
|
||||||
|
AdminBootstrapStatusResponse,
|
||||||
|
AppUser,
|
||||||
|
SessionUser,
|
||||||
|
} from '../../shared/types';
|
||||||
import { apiRequest } from './api';
|
import { apiRequest } from './api';
|
||||||
|
|
||||||
export function getUserDisplayName(user: AppUser | SessionUser | null): string {
|
export function getUserDisplayName(user: AppUser | SessionUser | null): string {
|
||||||
@@ -38,3 +44,16 @@ export async function signOutWithLocalAuth(sessionId?: string) {
|
|||||||
body: JSON.stringify(sessionId ? { sessionId } : {}),
|
body: JSON.stringify(sessionId ? { sessionId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAdminBootstrapStatus(): Promise<AdminBootstrapStatusResponse> {
|
||||||
|
return apiRequest<AdminBootstrapStatusResponse>('/admin/bootstrap/status');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function claimAdminBootstrap(payload: AdminBootstrapClaimRequest): Promise<SessionUser> {
|
||||||
|
const response = await apiRequest<AdminBootstrapClaimResponse>('/admin/bootstrap/claim', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.user;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user