-
Notifications
You must be signed in to change notification settings - Fork 70
Unified authentication: a single sign-in surface for portal and admin #284
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 50 commits
Commits
Show all changes
85 commits
Select commit
Hold shift + click to select a range
3dea114
feat(auth): add isTeamCallback helper for unified login routing
mortondev 92f4c18
feat(auth): surface admin login error codes on the unified login
mortondev f45081d
feat(auth): /auth/login serves team break-glass form for admin callbacks
mortondev a729aed
feat(auth): redirect /admin/login into the unified portal login
mortondev bb63246
feat(auth): repoint team-only auth redirects to the unified login
mortondev c280bcf
feat(auth): keep unified login + admin redirect onboarding-exempt
mortondev a98ddce
test(auth): e2e for unified login + team break-glass
mortondev 2cb0c9e
test(auth): make break-glass e2e (case d) actually disable portal met…
mortondev e35ff50
feat(auth): add identity_provider table + domain provider_id
mortondev cfe3026
feat(auth): backfill identity providers in place from ssoOidc + custo…
mortondev b9f56bf
feat(auth): identity-provider repository/service
mortondev d6a8f89
feat(auth): register genericOAuth + portal providers dynamically from…
mortondev 324b1c5
feat(auth): generalize provider dispatch from sso to the provider reg…
mortondev 6469752
feat(auth): per-provider domain routing, enforcement, and provisioning
mortondev db05408
feat(auth): render portal provider buttons from the identity-provider…
mortondev e3f5412
feat(auth): admin server functions for identity-provider CRUD
mortondev 0e02314
feat(auth): identity-provider list + editor UI
mortondev 5d6bfca
fix(auth): don't show a misleading Test button for untested identity …
mortondev 3b787ca
feat(auth): collapse Security to 2 tabs; nest 2FA under Password
mortondev fbed06e
feat(auth): retire /sso page; recovery codes move into Sign-in tab
mortondev c6e64d3
feat(auth): config-file declares identity providers as a list
mortondev f093555
fix(auth): stop misclassifying generic auth.* OIDC issuers as Okta
mortondev ec5366f
feat(auth): integration sweep + deferred legacy ssoOidc/custom-oidc c…
mortondev 9caca04
fix(auth): close branch-review findings (portal button-only lockout, …
mortondev 560724b
refactor(auth): scope onboarding ssoEnabled to the 'sso' provider; pa…
mortondev f44b3d3
fix(auth): close code-review findings (routed+showButton account-wipe…
mortondev 04b2471
feat(auth): thread callbackUrl through the auth dialog + add recovery…
mortondev 1494697
fix(auth): show recovery link in SSO-only Stage 1 (break-glass gap)
mortondev 512c8ef
feat(auth): auto-open the portal sign-in dialog from a ?signin redirect
mortondev 00a5ef0
feat(auth): private-portal gate honors callbackUrl + auto-opens the u…
mortondev 3ff35a3
fix(auth): harden callbackUrl validation against backslash open-redirect
mortondev 10c0607
feat(auth): instant-SSO redirect for single-provider SSO-only workspaces
mortondev bc0865e
refactor(auth): move use-auto-open-auth into components/ to satisfy l…
mortondev 7a0ddf8
refactor(auth): repoint all /auth/login redirects to the portal sign-…
mortondev 1ca89fc
refactor(auth): retire /auth/login + /auth/signup pages; delete team/…
mortondev a2802d7
feat(auth): require recovery codes before SSO enforcement can lock ou…
mortondev fa9a52f
feat(auth): add Admin entry to the portal user dropdown for team members
mortondev 6bac03d
test(auth): e2e journeys for the unified sign-in dialog
mortondev 7dd2f65
test(auth): complete private-portal gate sign-in journey + restore no…
mortondev b10dfb2
fix(auth): guard gate auto-open to unauthenticated + test instant-SSO…
mortondev f628a7f
fix(auth): single post-login navigation + correct 2FA callbackUrl + s…
mortondev 7db9e56
fix(auth): surface blocked sign-ins + harden instant-SSO (single-OIDC…
mortondev bb78d7a
refactor(auth): unify auth-prompt parsing + dedupe redirect helpers +…
mortondev 1091d34
refactor(auth): use auth=signin/auth=signup search param (cleaner URL…
mortondev c221add
fix(auth): scope the recovery-code link to the SSO views only
mortondev 486abce
fix(auth): full-navigate to /admin after sign-in to avoid a blank tra…
mortondev 9e0a53d
feat(auth): per-provider SSO test sign-in
mortondev 05173ea
feat(auth): unified sign-in hardening, IdP kind persistence, portal f…
mortondev 14715d5
refactor(auth): remove dead code surfaced by the unified-auth migration
mortondev 1120143
refactor(auth): regroup the Security sign-in settings and refresh copy
mortondev 8ca10d9
fix(e2e): stop logging the portal oauth config in the auth-methods he…
mortondev e82001f
feat(auth): add unified sign-in-method default helper
mortondev 907325b
feat(auth): backfill unified sign-in methods at startup
mortondev 44923aa
refactor(auth): gate every role on authConfig.oauth
mortondev d230df9
refactor(auth): drop surface from lookupAuthMethodsFn
mortondev ccfd2f3
fix(auth): instant-SSO reads the unified sign-in config
mortondev 8050ce3
refactor(auth): serve sign-in methods from publicAuthConfig only
mortondev 72c5f0e
refactor(auth): write sign-in methods to authConfig only
mortondev 0cdda7c
refactor(auth): retire portalConfig.oauth; single-config provider reg…
mortondev d1329f1
fix(auth): lockout backstop honors the opt-in magic-link default
mortondev ff99993
fix(auth): propagate the sign-in-methods backfill (cache + auth-version)
mortondev 69aec1f
fix(auth): preserve current path for 2FA redirect when dialog opens w…
mortondev 7268693
fix(auth): reset auth after the custom-OIDC startup backfill inserts …
mortondev 066077f
fix(auth): SSRF-guard the IdP discoveryUrl before write
mortondev 24c2f4b
fix(auth): restamp detailsChangedAt when IdP connection fields change
mortondev 0cb4a42
fix(auth): guard managed IdP paths in upsertIdentityProviderFn
mortondev 5492ba7
refactor(config-file): configure OIDC providers via in-app UI only
mortondev 4186419
refactor(config-file): manage only workspace + tierLimits (drop auth …
mortondev e749d53
fix(auth): address unified-auth migration-review follow-ups
mortondev 96fbac5
refactor(portal): remove transitional "Signing in…" header state
mortondev 62c7c82
feat(auth): expose twoFactor.required to the auth dialog
mortondev 5a12e1d
refactor(auth): drop server 2FA sign-in gates; enrollment moves to th…
mortondev b939778
refactor(auth): drop server 2FA sign-in gates; enrollment moves to th…
mortondev fc99a87
refactor(auth): extract shared TwoFactorEnrollSteps component
mortondev f85c275
feat(auth): inline 2FA challenge + enrollment in the auth dialog
mortondev 6a60434
refactor(auth): remove /auth/two-factor* pages and dead 2FA redirect …
mortondev 57d509e
fix(auth): correct 2FA docs + resilient dialog cancel (review follow-…
mortondev 26f2277
fix(auth): close 4 Codex SSO/credential review items
mortondev 396fecf
feat(auth): support testing manual-endpoint OIDC providers
mortondev a7d09cd
fix(auth): guard loader location + align sso-test-callback expectation
mortondev 844d180
fix(auth): close 3 follow-up Codex review items on the IdP changes
mortondev c199c08
fix(auth): revoke session on 2FA abandon + count default-on password
mortondev 120650c
fix(auth): stop flaky "window is not defined" in 2FA enroll tests
mortondev 63b35e2
refactor(auth): remove dead code and dedupe in unified-auth
mortondev 7e30596
feat(portal): inline the shared auth form on the private-portal gate
mortondev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| /** | ||
| * CLI: refresh e2e/.auth/admin.json with a current session token for | ||
| * demo@example.com. Uses the most recent valid session row directly from the | ||
| * DB and constructs the signed cookie (HMAC-SHA256(token, SECRET_KEY)) so the | ||
| * stored auth-state works without navigating through the magic-link flow. | ||
| * | ||
| * Run this instead of global-setup when the admin session has expired and | ||
| * magic-link sign-in is blocked by twoFactor.required (or any other 2FA gate). | ||
| * | ||
| * Usage: bun refresh-admin-session.ts | ||
| */ | ||
| import postgres from 'postgres' | ||
| import { createHmac } from 'crypto' | ||
| import { writeFileSync } from 'fs' | ||
| import { resolve } from 'path' | ||
|
|
||
| const connectionString = process.env.DATABASE_URL | ||
| if (!connectionString) { | ||
| console.error('DATABASE_URL environment variable is required') | ||
| process.exit(1) | ||
| } | ||
| const secret = process.env.SECRET_KEY | ||
| if (!secret) { | ||
| console.error('SECRET_KEY environment variable is required') | ||
| process.exit(1) | ||
| } | ||
|
|
||
| const sql = postgres(connectionString) | ||
|
|
||
| try { | ||
| // Get the most recent valid session for the admin user. | ||
| const rows = await sql` | ||
| SELECT s.token, s.expires_at | ||
| FROM session s | ||
| JOIN "user" u ON u.id = s.user_id | ||
| WHERE u.email = 'demo@example.com' | ||
| AND s.expires_at > NOW() | ||
| ORDER BY s.created_at DESC | ||
| LIMIT 1 | ||
| ` | ||
|
|
||
| if (rows.length === 0) { | ||
| console.error( | ||
| 'No valid session found for demo@example.com. ' + | ||
| 'Run the global-setup project (bun run test:e2e --project=setup) first.' | ||
| ) | ||
| await sql.end() | ||
| process.exit(1) | ||
| } | ||
|
|
||
| const token = rows[0].token as string | ||
| const expiresAt = rows[0].expires_at as Date | ||
|
|
||
| // Reproduce better-auth's setSignedCookie format: token.HMAC-SHA256(token, secret) | ||
| const sig = createHmac('sha256', secret).update(token).digest('base64') | ||
| const cookieValue = `${token}.${sig}` | ||
|
|
||
| // Write the storageState JSON that Playwright understands. | ||
| const state = { | ||
| cookies: [ | ||
| { | ||
| name: '__Secure-better-auth.session_token', | ||
| value: cookieValue, | ||
| domain: 'acme.localhost', | ||
| path: '/', | ||
| expires: expiresAt.getTime() / 1000, | ||
| httpOnly: true, | ||
| secure: true, | ||
| sameSite: 'Lax' as const, | ||
| }, | ||
| ], | ||
| origins: [], | ||
| } | ||
|
|
||
| const outPath = resolve(import.meta.dirname, '../.auth/admin.json') | ||
| writeFileSync(outPath, JSON.stringify(state, null, 2)) | ||
| console.log( | ||
| JSON.stringify({ action: 'refresh-admin-session', token: token.slice(0, 8) + '…', expiresAt }) | ||
| ) | ||
| await sql.end() | ||
| } catch (err) { | ||
| console.error(err instanceof Error ? err.message : String(err)) | ||
| await sql.end() | ||
| process.exit(1) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,133 @@ | ||
| /** | ||
| * CLI: seed or remove an `identity_provider` row (+ its encrypted client | ||
| * credential and an optional verified domain) for the identity-providers e2e. | ||
| * | ||
| * settings.* columns are JSON text; the provider model lives in the | ||
| * `identity_provider`, `sso_verified_domain`, and `integration_platform_credentials` | ||
| * tables. We write raw SQL (same style as the other e2e scripts) and generate | ||
| * TypeIDs via `@quackback/ids` (the column default is a JS-level drizzle default | ||
| * that a raw insert wouldn't trigger). | ||
| * | ||
| * The client secret is encrypted with the SAME AES-256-GCM + HKDF scheme as the | ||
| * app's `encryptPlatformCredentials` (purpose `integration-platform-credentials`) | ||
| * and stored at integration_type `auth_<registrationId>`, so the provider | ||
| * satisfies the runtime registration gate (enabled + creds + tier). The | ||
| * registration/routing/button gates only check that the credential ROW exists | ||
| * (no decrypt), so the secret never needs to round-trip — but encrypting it | ||
| * properly keeps the row indistinguishable from an app-written one. | ||
| * | ||
| * Usage: | ||
| * bun seed-identity-provider.ts seed '<json>' | ||
| * bun seed-identity-provider.ts remove <registrationId> | ||
| * | ||
| * seed JSON: { | ||
| * registrationId, label, clientId, | ||
| * discoveryUrl?, enabled?=true, showButton?=false, clientSecret?='e2e-secret', | ||
| * domain?: { name, verified?=true, enforced?=false } | ||
| * } | ||
| * | ||
| * After mutating, the caller (access-helpers) drops the tenant-settings + | ||
| * configured-types Redis caches so the running dev server sees the change. | ||
| */ | ||
| import postgres from 'postgres' | ||
| import { hkdfSync, randomBytes, createCipheriv, randomUUID } from 'crypto' | ||
| import { generateId, toUuid } from '@quackback/ids' | ||
|
|
||
| const action = (process.argv[2] || '').toLowerCase() | ||
| if (action !== 'seed' && action !== 'remove') { | ||
| console.error("Usage: bun seed-identity-provider.ts <seed '<json>' | remove <registrationId>>") | ||
| process.exit(1) | ||
| } | ||
|
|
||
| const connectionString = process.env.DATABASE_URL | ||
| if (!connectionString) { | ||
| console.error('DATABASE_URL environment variable is required') | ||
| process.exit(1) | ||
| } | ||
|
|
||
| /** Mirror of `encryptPlatformCredentials` (lib/server/integrations/encryption.ts): | ||
| * HKDF-SHA256(SECRET_KEY, salt, info) -> AES-256-GCM, format iv.tag.ct (base64url). */ | ||
| function encryptPlatformCredentials(creds: Record<string, string>): string { | ||
| const secretKey = process.env.SECRET_KEY | ||
| if (!secretKey) throw new Error('SECRET_KEY environment variable is required') | ||
| const info = 'quackback:v1:integration-platform-credentials' | ||
| const key = Buffer.from(hkdfSync('sha256', secretKey, 'quackback-encryption-salt-v1', info, 32)) | ||
| const iv = randomBytes(12) | ||
| const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }) | ||
| const ct = Buffer.concat([cipher.update(JSON.stringify(creds), 'utf8'), cipher.final()]) | ||
| const tag = cipher.getAuthTag() | ||
| return [iv.toString('base64url'), tag.toString('base64url'), ct.toString('base64url')].join('.') | ||
| } | ||
|
|
||
| interface SeedConfig { | ||
| registrationId: string | ||
| label: string | ||
| clientId: string | ||
| discoveryUrl?: string | ||
| enabled?: boolean | ||
| showButton?: boolean | ||
| clientSecret?: string | ||
| domain?: { name: string; verified?: boolean; enforced?: boolean } | ||
| } | ||
|
|
||
| const sql = postgres(connectionString) | ||
|
|
||
| async function remove(registrationId: string): Promise<void> { | ||
| // Deleting the provider cascades its sso_verified_domain rows (FK on delete | ||
| // cascade); the credential has no FK, so drop it explicitly. | ||
| await sql`DELETE FROM identity_provider WHERE registration_id = ${registrationId}` | ||
| await sql`DELETE FROM integration_platform_credentials WHERE integration_type = ${`auth_${registrationId}`}` | ||
| } | ||
|
|
||
| async function seed(cfg: SeedConfig): Promise<void> { | ||
| // Idempotent: clear any prior row for this registrationId first. | ||
| await remove(cfg.registrationId) | ||
|
|
||
| const idpUuid = toUuid(generateId('identity_provider')) | ||
| await sql` | ||
| INSERT INTO identity_provider | ||
| (id, registration_id, label, client_id, discovery_url, enabled, auto_create_users, show_button, created_at) | ||
| VALUES | ||
| (${idpUuid}, ${cfg.registrationId}, ${cfg.label}, ${cfg.clientId}, ${cfg.discoveryUrl ?? null}, | ||
| ${cfg.enabled ?? true}, true, ${cfg.showButton ?? false}, NOW())` | ||
|
|
||
| const credUuid = toUuid(generateId('platform_cred')) | ||
| const secrets = encryptPlatformCredentials({ clientSecret: cfg.clientSecret ?? 'e2e-secret' }) | ||
| await sql` | ||
| INSERT INTO integration_platform_credentials (id, integration_type, secrets, created_at, updated_at) | ||
| VALUES (${credUuid}, ${`auth_${cfg.registrationId}`}, ${secrets}, NOW(), NOW())` | ||
|
|
||
| if (cfg.domain) { | ||
| const domUuid = toUuid(generateId('domain')) | ||
| const verifiedAt = cfg.domain.verified === false ? null : new Date() | ||
| await sql` | ||
| INSERT INTO sso_verified_domain | ||
| (id, name, verification_token, verified_at, enforced, provider_id, created_at) | ||
| VALUES | ||
| (${domUuid}, ${cfg.domain.name}, ${`e2e-${randomUUID()}`}, ${verifiedAt}, | ||
| ${cfg.domain.enforced ?? false}, ${idpUuid}, NOW())` | ||
| } | ||
| } | ||
|
|
||
| try { | ||
| if (action === 'remove') { | ||
| const registrationId = process.argv[3] | ||
| if (!registrationId) throw new Error('remove requires a <registrationId>') | ||
| await remove(registrationId) | ||
| console.log(JSON.stringify({ action: 'remove', registrationId })) | ||
| } else { | ||
| const raw = process.argv[3] | ||
| if (!raw) throw new Error("seed requires a '<json>' config") | ||
| const cfg = JSON.parse(raw) as SeedConfig | ||
| if (!cfg.registrationId || !cfg.label || !cfg.clientId) { | ||
| throw new Error('seed config requires registrationId, label, clientId') | ||
| } | ||
| await seed(cfg) | ||
| console.log(JSON.stringify({ action: 'seed', registrationId: cfg.registrationId })) | ||
| } | ||
| await sql.end() | ||
| } catch (err) { | ||
| console.error(err instanceof Error ? err.message : String(err)) | ||
| await sql.end() | ||
| process.exit(1) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| /** | ||
| * CLI: disable or restore portal public auth methods in settings.portal_config. | ||
| * settings.portal_config is a JSON *text* column, so we read → patch → write. | ||
| * There is a single workspace settings row. | ||
| * | ||
| * When disabling: all stored oauth keys plus the known core methods (password, | ||
| * magicLink) are set to false — no portal sign-in method is presented to | ||
| * public users. The team break-glass form (TeamLoginForm) still appears for | ||
| * team-bound callbackUrls; that is the invariant this helper enables testing. | ||
| * | ||
| * When restoring: oauth is reset to the default portal config values | ||
| * (mirrors DEFAULT_PORTAL_CONFIG.oauth — password + standard OAuth on, | ||
| * magicLink off). | ||
| * | ||
| * Usage: bun set-portal-auth-methods.ts <disable|restore> | ||
| */ | ||
| import postgres from 'postgres' | ||
|
|
||
| const arg = (process.argv[2] || '').toLowerCase() | ||
| if (arg !== 'disable' && arg !== 'restore' && arg !== 'enable-magic-link') { | ||
| console.error('Usage: bun set-portal-auth-methods.ts <disable|restore|enable-magic-link>') | ||
| process.exit(1) | ||
| } | ||
|
|
||
| const connectionString = process.env.DATABASE_URL | ||
| if (!connectionString) { | ||
| console.error('DATABASE_URL environment variable is required') | ||
| process.exit(1) | ||
| } | ||
| const sql = postgres(connectionString) | ||
|
|
||
| try { | ||
| const rows = await sql`SELECT id, portal_config FROM settings ORDER BY created_at ASC LIMIT 1` | ||
| if (rows.length === 0) throw new Error('No settings row found') | ||
| const id = rows[0].id | ||
| let config: Record<string, unknown> = {} | ||
| if (rows[0].portal_config) { | ||
| try { | ||
| config = JSON.parse(rows[0].portal_config as string) | ||
| } catch { | ||
| config = {} | ||
| } | ||
| } | ||
|
|
||
| if (arg === 'disable') { | ||
| // Turn off every portal oauth method currently stored plus the core keys. | ||
| // Iterating existing keys handles any dynamic OAuth providers (custom-oidc, etc.) | ||
| // that may have been configured without this script knowing about them. | ||
| const existing = (config.oauth as Record<string, unknown>) ?? {} | ||
| const disabled: Record<string, unknown> = {} | ||
| for (const key of Object.keys(existing)) { | ||
| disabled[key] = false | ||
| } | ||
| // Ensure the canonical methods are explicitly disabled even if not yet stored. | ||
| disabled.password = false | ||
| disabled.magicLink = false | ||
| config.oauth = disabled | ||
| } else if (arg === 'enable-magic-link') { | ||
| // Enable only the magicLink method, leaving other settings untouched. Used | ||
| // by test setup that needs to sign in portal users (role='user') on repeat | ||
| // runs: the hooks check blocks magic-link for existing portal users when | ||
| // magicLink is off, so we open it just for the sign-in then restore. | ||
| const existing = (config.oauth as Record<string, unknown>) ?? {} | ||
| config.oauth = { ...existing, magicLink: true } | ||
| } else { | ||
| // Restore to the default portal oauth config (mirrors DEFAULT_PORTAL_CONFIG.oauth). | ||
| config.oauth = { password: true, email: false, google: true, github: true } | ||
| } | ||
|
|
||
| await sql`UPDATE settings SET portal_config = ${JSON.stringify(config)} WHERE id = ${id}` | ||
| console.log(JSON.stringify({ action: arg, oauth: config.oauth })) | ||
| await sql.end() | ||
| } catch (err) { | ||
| console.error(err instanceof Error ? err.message : String(err)) | ||
| await sql.end() | ||
| process.exit(1) | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| /** | ||
| * CLI: set portal visibility to 'public' or 'private' in settings.portal_config. | ||
| * settings.portal_config is a JSON *text* column, so we read → patch → write. | ||
| * There is a single workspace settings row. | ||
| * | ||
| * When set to 'private': unauthenticated visitors hit the PortalAccessGate | ||
| * before seeing any portal content. Used by e2e tests that verify the gate | ||
| * renders with the dialog auto-opened (journey 3). | ||
| * | ||
| * Always restore to 'public' in a `finally` block so subsequent tests and | ||
| * dev sessions are not left with a locked portal. | ||
| * | ||
| * Usage: bun set-portal-visibility.ts <private|public> | ||
| */ | ||
| import postgres from 'postgres' | ||
|
|
||
| const arg = (process.argv[2] || '').toLowerCase() | ||
| if (arg !== 'private' && arg !== 'public') { | ||
| console.error('Usage: bun set-portal-visibility.ts <private|public>') | ||
| process.exit(1) | ||
| } | ||
|
|
||
| const connectionString = process.env.DATABASE_URL | ||
| if (!connectionString) { | ||
| console.error('DATABASE_URL environment variable is required') | ||
| process.exit(1) | ||
| } | ||
| const sql = postgres(connectionString) | ||
|
|
||
| try { | ||
| const rows = await sql`SELECT id, portal_config FROM settings ORDER BY created_at ASC LIMIT 1` | ||
| if (rows.length === 0) throw new Error('No settings row found') | ||
| const id = rows[0].id | ||
| let config: Record<string, unknown> = {} | ||
| if (rows[0].portal_config) { | ||
| try { | ||
| config = JSON.parse(rows[0].portal_config as string) | ||
| } catch { | ||
| config = {} | ||
| } | ||
| } | ||
|
|
||
| // Merge the visibility into the access sub-object, preserving other keys. | ||
| const existingAccess = (config.access as Record<string, unknown>) ?? {} | ||
| config.access = { ...existingAccess, visibility: arg } | ||
|
|
||
| await sql`UPDATE settings SET portal_config = ${JSON.stringify(config)} WHERE id = ${id}` | ||
| console.log(JSON.stringify({ action: 'set-portal-visibility', visibility: arg })) | ||
| await sql.end() | ||
| } catch (err) { | ||
| console.error(err instanceof Error ? err.message : String(err)) | ||
| await sql.end() | ||
| process.exit(1) | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.