mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
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:
1 parent
1214108d6b
commit
c6166de886
36 files changed
+1729
-109
No files matched your search
@@ -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()}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user