feat: instance administration, bootstrap admin, and managed-tier OAuth gate

Self-hosted instances are now email/password only — OAuth providers
(GitHub/Google/Discord/Microsoft) activate solely when PRIMORA_MANAGED=true,
reserved for the future hosted tier.

Sign-up lifecycle: the first public sign-up becomes the instance admin; after
bootstrap, sign-up closes by default. A better-auth databaseHook gates only
/sign-up/email, so admin-created users and OAuth flows bypass it. The login
page reads GET /instance/public to hide the sign-up tab, show the bootstrap
notice, and render only the providers the instance offers.

Instance settings live in core.settings (migration 00010) and resolve
in-app > env > built-in default, cached 15s. Platform admins (better-auth
user.role) manage sign-up, mail transport, and rate limits via
GET/PUT/DELETE /instance/settings or the new Settings → Instance tab. Secret
values are AES-256-GCM encrypted at rest with PRIMORA_ENCRYPTION_KEY, never
returned by the API, and decrypted by the auth service at send time.

Auth service reads the same settings table for the sign-up gate and mail
config, so in-app changes take effect without a restart.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-21 17:46:47 +02:00
1 parent 1214108d6b
commit c6166de886
36 files changed
+1729 -109

No files matched your search

+80
View File
@@ -4,6 +4,7 @@ import {
CreateInvitationRequest,
CreateOrganizationRequest,
HealthService,
InstanceService,
OrganizationsService,
PlatformService,
ProjectsService,
@@ -18,6 +19,7 @@ import {
CopyBucketObjectRequest,
type Bucket,
type BucketObject,
type InstanceSetting,
type OrganizationSummary,
type ProjectSummary,
} from "@primora/api-client";
@@ -141,6 +143,7 @@ export default function App() {
const [memberMessage, setMemberMessage] = createSignal("");
const [apiKeySecret, setApiKeySecret] = createSignal("");
const [collectionsMessage, setCollectionsMessage] = createSignal("");
const [instanceMessage, setInstanceMessage] = createSignal("");
const [selectedOrganizationID, setSelectedOrganizationID] = createSignal<string | undefined>();
const [selectedProjectID, setSelectedProjectID] = createSignal<string | undefined>();
@@ -237,6 +240,47 @@ export default function App() {
}
});
// Public instance state — fetched before login so the auth page can hide
// sign-up/social options the instance does not offer.
const [instancePublic] = createResource(async () => {
if (isDemo) return { signup_enabled: true, bootstrap_required: false, social_providers: [] };
try {
return await InstanceService.getInstancePublic();
} catch {
// Endpoint unreachable (backend down or pre-migration): keep sign-in
// visible rather than lock the page out.
return undefined;
}
});
const canSignUp = createMemo(() => {
const cfg = instancePublic();
if (!cfg) return true; // unknown — let the backend enforce
return cfg.bootstrap_required || cfg.signup_enabled;
});
const enabledSocialProviders = createMemo(() => instancePublic()?.social_providers ?? []);
const isPlatformAdmin = createMemo(() => {
const role = (session()?.data?.user as { role?: string } | undefined)?.role;
return isDemo || role === "admin";
});
const [instanceSettings, { refetch: refetchInstanceSettings }] = createResource(
() => (activeView() === "settings" && isPlatformAdmin() && session()?.data ? true : null),
async (): Promise<InstanceSetting[]> => {
if (isDemo) return demoService.listInstanceSettings();
return (await InstanceService.listInstanceSettings()).settings;
},
);
// Clamp the auth mode to what the instance permits.
createEffect(() => {
const cfg = instancePublic();
if (!cfg || isDemo) return;
if (cfg.bootstrap_required) setMode("sign-up");
else if (!cfg.signup_enabled) setMode("sign-in");
});
const [platform, { refetch: refetchPlatform }] = createResource(
() => session()?.data?.user.id ?? null,
async () => {
@@ -312,6 +356,7 @@ export default function App() {
const storagePending = createMemo(() => isPendingPrefix("storage-"));
const collectionPending = createMemo(() => isPendingPrefix("collection-"));
const documentPending = createMemo(() => isPendingPrefix("document-"));
const instancePending = createMemo(() => isPendingPrefix("instance-"));
const [apiKeys, { refetch: refetchAPIKeys }] = createResource(
() => activeProject()?.id ?? null,
@@ -1431,6 +1476,32 @@ export default function App() {
});
}
async function saveInstanceSetting(key: string, value: unknown) {
await runPending("instance-", async () => {
setInstanceMessage("");
try {
await InstanceService.updateInstanceSetting({ key, requestBody: { value } });
await refetchInstanceSettings();
setInstanceMessage("Setting saved.");
} catch (error) {
setInstanceMessage(getErrorMessage(error, "Failed to save setting"));
}
});
}
async function resetInstanceSetting(key: string) {
await runPending("instance-", async () => {
setInstanceMessage("");
try {
await InstanceService.deleteInstanceSetting({ key });
await refetchInstanceSettings();
setInstanceMessage(`"${key}" reverted to its env/default value.`);
} catch (error) {
setInstanceMessage(getErrorMessage(error, "Failed to reset setting"));
}
});
}
async function startSocial(provider: "github" | "google" | "discord" | "microsoft") {
if (isDemo) {
setAuthMessage("Demo mode is active — social auth is simulated.");
@@ -1500,6 +1571,9 @@ export default function App() {
name={name()}
authMessage={authMessage()}
authPending={authPending()}
canSignUp={canSignUp()}
bootstrapRequired={instancePublic()?.bootstrap_required ?? false}
enabledProviders={enabledSocialProviders()}
onModeChange={setMode}
onEmailChange={setEmail}
onPasswordChange={setPassword}
@@ -1816,6 +1890,12 @@ export default function App() {
<Show when={activeView() === "settings"}>
<SettingsPage
isPlatformAdmin={isPlatformAdmin()}
instanceSettings={instanceSettings()}
instancePending={instancePending() || instanceSettings.loading}
instanceMessage={instanceMessage()}
onSaveInstanceSetting={(key, value) => void saveInstanceSetting(key, value)}
onResetInstanceSetting={(key) => void resetInstanceSetting(key)}
apiKeys={apiKeys()}
apiKeyName={apiKeyName()}
apiKeyScopes={apiKeyScopes()}
+14
View File
@@ -23,6 +23,7 @@ import type {
Document,
IntegrationAnalytics,
IntegrationTestResult,
InstanceSetting,
MeResponse,
MetricPoint,
OrganizationSummary,
@@ -608,6 +609,19 @@ class DemoService {
}));
}
async listAuthUsers() { await this.delay(); return authUsers; }
// Mirrors InstanceService.listInstanceSettings — representative registry
// slice so demo mode can show the Instance settings tab.
async listInstanceSettings(): Promise<InstanceSetting[]> {
await this.delay();
return [
{ key: "auth.signup_enabled", type: "bool", secret: false, is_set: false, source: "default", value: false },
{ key: "mail.from", type: "string", secret: false, is_set: false, source: "env", value: "Primora <[email protected]>" },
{ key: "mail.smtp_host", type: "string", secret: false, is_set: true, source: "app", value: "mailpit" },
{ key: "mail.smtp_password", type: "string", secret: true, is_set: false, source: "default" },
{ key: "ratelimit.user_per_minute", type: "int", secret: false, is_set: false, source: "default", value: 240 },
] as InstanceSetting[];
}
async listCollections() { await this.delay(); return collections; }
async listDocuments() { await this.delay(); return documents; }
+73 -40
View File
@@ -18,6 +18,12 @@ interface LoginPageProps {
name: string;
authMessage: string;
authPending: boolean;
/** False when the instance closed public sign-up (and bootstrap is done). */
canSignUp: boolean;
/** True while no user exists — the first sign-up becomes instance admin. */
bootstrapRequired: boolean;
/** Social providers the instance offers (empty on self-hosted). */
enabledProviders: string[];
onModeChange: (mode: AuthMode) => void;
onEmailChange: (value: string) => void;
onPasswordChange: (value: string) => void;
@@ -35,7 +41,9 @@ const socialProviders: { id: Provider; label: string; logo: (p: { class?: string
];
export function LoginPage(props: LoginPageProps) {
const isSignIn = () => props.mode === "sign-in";
const isSignIn = () => !props.bootstrapRequired && props.mode === "sign-in";
const providers = () =>
socialProviders.filter((p) => props.enabledProviders.includes(p.id));
return (
<div class="auth-wrap">
@@ -70,36 +78,47 @@ export function LoginPage(props: LoginPageProps) {
<span class="font-semibold tracking-tight" style="color: var(--text-1)">Primora</span>
</div>
<h1 style="font-size: 1.25rem; letter-spacing: -0.02em;">
{isSignIn() ? "Sign in" : "Create your account"}
{isSignIn() ? "Sign in" : props.bootstrapRequired ? "Create the admin account" : "Create your account"}
</h1>
<p class="mt-1" style="font-size: 0.8125rem; color: var(--text-3)">
{isSignIn()
? "Access your workspace"
: "One account for the whole workspace"}
: props.bootstrapRequired
? "The first account becomes the instance administrator"
: "One account for the whole workspace"}
</p>
</div>
{/* mode toggle */}
<div class="seg mb-6" role="tablist" aria-label="Authentication mode">
<button
type="button"
role="tab"
aria-selected={isSignIn()}
class={`seg-item ${isSignIn() ? "active" : ""}`}
onClick={() => props.onModeChange("sign-in")}
>
Sign in
</button>
<button
type="button"
role="tab"
aria-selected={!isSignIn()}
class={`seg-item ${!isSignIn() ? "active" : ""}`}
onClick={() => props.onModeChange("sign-up")}
>
Sign up
</button>
</div>
{/* mode toggle — hidden during bootstrap; sign-up tab hidden when closed */}
<Show when={!props.bootstrapRequired}>
<div class="seg mb-6" role="tablist" aria-label="Authentication mode">
<button
type="button"
role="tab"
aria-selected={isSignIn()}
class={`seg-item ${isSignIn() ? "active" : ""}`}
onClick={() => props.onModeChange("sign-in")}
>
Sign in
</button>
<Show when={props.canSignUp}>
<button
type="button"
role="tab"
aria-selected={!isSignIn()}
class={`seg-item ${!isSignIn() ? "active" : ""}`}
onClick={() => props.onModeChange("sign-up")}
>
Sign up
</button>
</Show>
</div>
</Show>
<Show when={props.bootstrapRequired}>
<div class="message message-info mb-6">
This instance has no users yet. Create the first account — it becomes the administrator.
</div>
</Show>
<form onSubmit={props.onSubmit} class="space-y-4">
<Show when={!isSignIn()}>
@@ -143,29 +162,43 @@ export function LoginPage(props: LoginPageProps) {
>
<Show
when={props.authPending}
fallback={isSignIn() ? "Sign in" : "Create account"}
fallback={
isSignIn()
? "Sign in"
: props.bootstrapRequired
? "Create admin account"
: "Create account"
}
>
<span class="spinner" /> Working…
</Show>
</button>
</form>
<div class="divider my-6">or continue with</div>
<Show when={!props.canSignUp && !props.bootstrapRequired}>
<p class="mt-4 text-center" style="font-size: 0.8125rem; color: var(--text-3)">
Public sign-up is closed on this instance.
</p>
</Show>
<div class="grid grid-cols-4 gap-2">
{socialProviders.map((p) => (
<button
type="button"
class="btn btn-secondary btn-icon w-full"
onClick={() => props.onSocial(p.id)}
disabled={props.authPending}
title={`Continue with ${p.label}`}
aria-label={`Continue with ${p.label}`}
>
<p.logo class="w-4 h-4" />
</button>
))}
</div>
<Show when={providers().length > 0}>
<div class="divider my-6">or continue with</div>
<div class={`grid gap-2 ${providers().length === 1 ? "grid-cols-1" : providers().length === 2 ? "grid-cols-2" : "grid-cols-4"}`}>
{providers().map((p) => (
<button
type="button"
class="btn btn-secondary btn-icon w-full"
onClick={() => props.onSocial(p.id)}
disabled={props.authPending}
title={`Continue with ${p.label}`}
aria-label={`Continue with ${p.label}`}
>
<p.logo class="w-4 h-4" />
</button>
))}
</div>
</Show>
<Show when={props.onTryDemo}>
<div class="divider my-6" />
+183 -2
View File
@@ -1,5 +1,5 @@
import { For, Show, createSignal } from "solid-js";
import type { ApiKey } from "@primora/api-client";
import type { ApiKey, InstanceSetting } from "@primora/api-client";
import { Badge } from "../components/Badge";
import { Modal, ModalFooter } from "../components/Modal";
import { Input } from "../components/Input";
@@ -25,6 +25,12 @@ const API_KEY_SCOPES: Array<{ value: string; label: string; hint: string }> = [
];
interface SettingsPageProps {
isPlatformAdmin: boolean;
instanceSettings?: InstanceSetting[];
instancePending: boolean;
instanceMessage: string;
onSaveInstanceSetting: (key: string, value: unknown) => void;
onResetInstanceSetting: (key: string) => void;
apiKeys?: ApiKey[];
apiKeyName: string;
apiKeyScopes: string[];
@@ -50,8 +56,27 @@ interface SettingsPageProps {
formatDate: (value?: string | null) => string;
}
const INSTANCE_GROUPS: { prefix: string; title: string; hint: string }[] = [
{ prefix: "auth.", title: "Authentication", hint: "Who can create accounts on this instance." },
{ prefix: "mail.", title: "Mail", hint: "Outgoing email — Resend or SMTP. In-app values override .env." },
{ prefix: "ratelimit.", title: "Rate limits", hint: "Requests per minute, enforced by the API." },
];
const SETTING_LABELS: Record<string, string> = {
"auth.signup_enabled": "Public sign-up",
"mail.from": "From address",
"mail.resend_api_key": "Resend API key",
"mail.smtp_host": "SMTP host",
"mail.smtp_port": "SMTP port",
"mail.smtp_user": "SMTP user",
"mail.smtp_password": "SMTP password",
"mail.smtp_secure": "SMTP TLS",
"ratelimit.user_per_minute": "User requests / minute",
"ratelimit.api_key_per_minute": "API key requests / minute",
};
export function SettingsPage(props: SettingsPageProps) {
const [tab, setTab] = createSignal<"api-keys" | "organization">("api-keys");
const [tab, setTab] = createSignal<"api-keys" | "organization" | "instance">("api-keys");
const [copied, setCopied] = createSignal(false);
const [createOrgOpen, setCreateOrgOpen] = createSignal(false);
const [confirmDeleteOrg, setConfirmDeleteOrg] = createSignal(false);
@@ -94,6 +119,14 @@ export function SettingsPage(props: SettingsPageProps) {
>
Organization
</button>
<Show when={props.isPlatformAdmin}>
<button
class={`tab ${tab() === "instance" ? "active" : ""}`}
onClick={() => setTab("instance")}
>
Instance
</button>
</Show>
</div>
{/* ---------------- API keys ---------------- */}
@@ -322,6 +355,53 @@ export function SettingsPage(props: SettingsPageProps) {
</Show>
</Show>
{/* ---------------- Instance (platform admin) ---------------- */}
<Show when={tab() === "instance" && props.isPlatformAdmin}>
<Show when={props.instanceMessage}>
<div class="message message-neutral">{props.instanceMessage}</div>
</Show>
<p class="text-xs text-text-3 mb-4">
In-app values override the matching environment variables. Secrets are stored encrypted
and never shown back.
</p>
<For each={INSTANCE_GROUPS}>
{(group) => {
const rows = () => (props.instanceSettings ?? []).filter((s) => s.key.startsWith(group.prefix));
return (
<Show when={rows().length > 0}>
<div class="card card-flush mb-4">
<div class="card-header">
<span class="card-header-title">{group.title}</span>
<span class="card-header-description">{group.hint}</span>
</div>
<div class="divide-y" style="border-color:var(--border)">
<For each={rows()}>
{(setting) => (
<SettingRow
setting={setting}
pending={props.instancePending}
onSave={(value) => props.onSaveInstanceSetting(setting.key, value)}
onReset={() => props.onResetInstanceSetting(setting.key)}
/>
)}
</For>
</div>
</div>
</Show>
);
}}
</For>
<Show when={!props.instanceSettings}>
<div class="card empty-state">
<div class="empty-state-icon"><IconKey class="w-5 h-5" /></div>
<p class="empty-state-title">Loading instance settings</p>
</div>
</Show>
</Show>
{/* API key secret modal — shown while App holds a fresh secret */}
<Show when={props.apiKeySecret}>
<Modal
@@ -412,3 +492,104 @@ export function SettingsPage(props: SettingsPageProps) {
</div>
);
}
/** Values arrive as JSON strings ("true", "240") — decode per declared type. */
function settingText(setting: InstanceSetting): string {
if (setting.value === undefined || setting.value === null) return "";
return String(setting.value);
}
const SOURCE_BADGE: Record<string, "success" | "primary" | "neutral"> = {
app: "success",
env: "primary",
default: "neutral",
};
function SettingRow(props: {
setting: InstanceSetting;
pending: boolean;
onSave: (value: unknown) => void;
onReset: () => void;
}) {
const s = () => props.setting;
const [draft, setDraft] = createSignal(settingText(props.setting));
const dirty = () => {
if (s().type === "bool") return false; // toggles save instantly
return draft() !== settingText(s());
};
const saveDraft = () => {
if (s().type === "int") {
const n = Number(draft());
if (!Number.isInteger(n) || n < 0) return;
props.onSave(n);
return;
}
props.onSave(draft());
};
return (
<div class="flex items-center gap-3 px-4 py-3 flex-wrap">
<div class="flex-1" style="min-width:12rem">
<div class="text-sm font-medium text-text-1">
{SETTING_LABELS[s().key] ?? s().key}
</div>
<code class="text-xs text-text-3">{s().key}</code>
</div>
<Badge variant={SOURCE_BADGE[s().source] ?? "neutral"}>
{s().is_set ? "in-app" : s().source}
</Badge>
<Show
when={s().type === "bool"}
fallback={
<div class="flex items-center gap-2">
<input
class="input input-sm"
style="width:12rem"
type={s().secret ? "password" : s().type === "int" ? "number" : "text"}
min={s().type === "int" ? 0 : undefined}
placeholder={s().secret ? (s().is_set ? "•••••••• (stored)" : "not set") : ""}
value={draft()}
onInput={(e) => setDraft(e.currentTarget.value)}
disabled={props.pending}
aria-label={SETTING_LABELS[s().key] ?? s().key}
/>
<button
class="btn btn-secondary btn-sm"
onClick={saveDraft}
disabled={props.pending || !dirty()}
>
Save
</button>
</div>
}
>
<label class="flex items-center gap-2 text-sm text-text-2">
<input
type="checkbox"
class="accent-[var(--accent)]"
checked={settingText(s()) === "true"}
disabled={props.pending}
onChange={(e) => props.onSave(e.currentTarget.checked)}
aria-label={SETTING_LABELS[s().key] ?? s().key}
/>
{settingText(s()) === "true" ? "Enabled" : "Disabled"}
</label>
</Show>
<Show when={s().is_set}>
<button
class="btn btn-ghost btn-sm"
onClick={props.onReset}
disabled={props.pending}
title="Remove the in-app override — env/default applies again"
>
Reset
</button>
</Show>
</div>
);
}