fix(api): needsSetup guard ordering in setup auth endpoints (GRO-392 UAT fix) #215
@@ -187,4 +187,4 @@ authProviderRouter.delete("/", requireSuperUser(), async (c) => {
|
|||||||
await db.delete(authProviderConfig).where(eq(authProviderConfig.id, existing.id));
|
await db.delete(authProviderConfig).where(eq(authProviderConfig.id, existing.id));
|
||||||
|
|
||||||
return c.json({ ok: true, message: "Auth provider config removed; auth will fall back to env vars" });
|
return c.json({ ok: true, message: "Auth provider config removed; auth will fall back to env vars" });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -110,6 +110,12 @@ const authProviderBootstrapSchema = z.object({
|
|||||||
scopes: z.string().default("openid profile email"),
|
scopes: z.string().default("openid profile email"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Minimal schema for test endpoint — OIDC discovery only needs issuer/internal URLs
|
||||||
|
const authProviderTestSchema = z.object({
|
||||||
|
issuerUrl: z.string().url(),
|
||||||
|
internalBaseUrl: z.string().url().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/setup/auth-provider
|
* POST /api/setup/auth-provider
|
||||||
* Unauthenticated endpoint for first-time auth provider setup during OOBE.
|
* Unauthenticated endpoint for first-time auth provider setup during OOBE.
|
||||||
@@ -117,7 +123,7 @@ const authProviderBootstrapSchema = z.object({
|
|||||||
* Rate-limited by the API gateway; additionally restricted to first-time setup only.
|
* Rate-limited by the API gateway; additionally restricted to first-time setup only.
|
||||||
* After setup completes, this endpoint permanently returns 403.
|
* After setup completes, this endpoint permanently returns 403.
|
||||||
*/
|
*/
|
||||||
setupRouter.post("/auth-provider", zValidator("json", authProviderBootstrapSchema), async (c) => {
|
setupRouter.post("/auth-provider", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
// Guard: only allow during fresh install (no super user yet)
|
// Guard: only allow during fresh install (no super user yet)
|
||||||
@@ -143,7 +149,7 @@ setupRouter.post("/auth-provider", zValidator("json", authProviderBootstrapSchem
|
|||||||
return c.json({ error: "Auth provider is already configured." }, 409);
|
return c.json({ error: "Auth provider is already configured." }, 409);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = c.req.valid("json");
|
const body = authProviderBootstrapSchema.parse(await c.req.json());
|
||||||
|
|
||||||
// Encrypt clientSecret before storing
|
// Encrypt clientSecret before storing
|
||||||
const encryptedSecret = encryptSecret(body.clientSecret);
|
const encryptedSecret = encryptSecret(body.clientSecret);
|
||||||
@@ -186,7 +192,7 @@ setupRouter.post("/auth-provider", zValidator("json", authProviderBootstrapSchem
|
|||||||
* Fetches the OIDC discovery document to confirm the issuer is reachable.
|
* Fetches the OIDC discovery document to confirm the issuer is reachable.
|
||||||
* Only available when needsSetup is true (no super user = fresh install).
|
* Only available when needsSetup is true (no super user = fresh install).
|
||||||
*/
|
*/
|
||||||
setupRouter.post("/auth-provider/test", zValidator("json", authProviderBootstrapSchema), async (c) => {
|
setupRouter.post("/auth-provider/test", async (c) => {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
// Guard: only allow during fresh install (no super user yet)
|
// Guard: only allow during fresh install (no super user yet)
|
||||||
@@ -200,7 +206,7 @@ setupRouter.post("/auth-provider/test", zValidator("json", authProviderBootstrap
|
|||||||
return c.json({ ok: false, error: "Setup has already been completed." }, 403);
|
return c.json({ ok: false, error: "Setup has already been completed." }, 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = c.req.valid("json");
|
const body = authProviderTestSchema.parse(await c.req.json());
|
||||||
|
|
||||||
// Determine the discovery URL
|
// Determine the discovery URL
|
||||||
const discoveryUrl = body.internalBaseUrl
|
const discoveryUrl = body.internalBaseUrl
|
||||||
|
|||||||
@@ -1,6 +1,40 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useBranding } from "../BrandingContext.js";
|
import { useBranding } from "../BrandingContext.js";
|
||||||
|
|
||||||
|
interface AuthProviderConfig {
|
||||||
|
id: number;
|
||||||
|
providerId: string;
|
||||||
|
displayName: string;
|
||||||
|
issuerUrl: string;
|
||||||
|
internalBaseUrl: string | null;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
scopes: string;
|
||||||
|
enabled: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthProviderForm {
|
||||||
|
providerId: string;
|
||||||
|
displayName: string;
|
||||||
|
issuerUrl: string;
|
||||||
|
internalBaseUrl: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
scopes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REDACTED = "••••••••";
|
||||||
|
|
||||||
|
interface CurrentUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
isSuperUser: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface SettingsForm {
|
interface SettingsForm {
|
||||||
businessName: string;
|
businessName: string;
|
||||||
primaryColor: string;
|
primaryColor: string;
|
||||||
@@ -13,6 +47,28 @@ interface SettingsForm {
|
|||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { refresh } = useBranding();
|
const { refresh } = useBranding();
|
||||||
|
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||||
|
|
||||||
|
// Auth provider state
|
||||||
|
const [authConfig, setAuthConfig] = useState<AuthProviderConfig | null>(null);
|
||||||
|
const [authForm, setAuthForm] = useState<AuthProviderForm>({
|
||||||
|
providerId: "authentik",
|
||||||
|
displayName: "",
|
||||||
|
issuerUrl: "",
|
||||||
|
internalBaseUrl: "",
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
scopes: "openid profile email",
|
||||||
|
});
|
||||||
|
const [authSecretTouched, setAuthSecretTouched] = useState(false);
|
||||||
|
const [authLoaded, setAuthLoaded] = useState(false);
|
||||||
|
const [authSaving, setAuthSaving] = useState(false);
|
||||||
|
const [authMessage, setAuthMessage] = useState<{ type: "success" | "error"; text: string } | null>(null);
|
||||||
|
const [testingConnection, setTestingConnection] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<{ ok: boolean; error?: string } | null>(null);
|
||||||
|
const [showInternalBaseUrl, setShowInternalBaseUrl] = useState(false);
|
||||||
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
|
|
||||||
const [form, setForm] = useState<SettingsForm>({
|
const [form, setForm] = useState<SettingsForm>({
|
||||||
businessName: "",
|
businessName: "",
|
||||||
primaryColor: "#4f8a6f",
|
primaryColor: "#4f8a6f",
|
||||||
@@ -57,6 +113,33 @@ export function SettingsPage() {
|
|||||||
.catch(() => setLoaded(true));
|
.catch(() => setLoaded(true));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Load current user (for isSuperUser check) and auth provider config
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
fetch("/api/staff/me").then((r) => r.json()).catch(() => null),
|
||||||
|
fetch("/api/admin/auth-provider").then(async (r) => {
|
||||||
|
if (r.ok) return r.json();
|
||||||
|
if (r.status === 404) return null;
|
||||||
|
throw new Error(`HTTP ${r.status}`);
|
||||||
|
}).catch(() => null),
|
||||||
|
]).then(([user, auth]) => {
|
||||||
|
setCurrentUser(user as CurrentUser | null);
|
||||||
|
if (auth) {
|
||||||
|
setAuthConfig(auth as AuthProviderConfig);
|
||||||
|
setAuthForm({
|
||||||
|
providerId: (auth as AuthProviderConfig).providerId,
|
||||||
|
displayName: (auth as AuthProviderConfig).displayName,
|
||||||
|
issuerUrl: (auth as AuthProviderConfig).issuerUrl,
|
||||||
|
internalBaseUrl: (auth as AuthProviderConfig).internalBaseUrl ?? "",
|
||||||
|
clientId: (auth as AuthProviderConfig).clientId,
|
||||||
|
clientSecret: (auth as AuthProviderConfig).clientSecret,
|
||||||
|
scopes: (auth as AuthProviderConfig).scopes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setAuthLoaded(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -143,6 +226,105 @@ export function SettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auth provider handlers
|
||||||
|
const handleTestConnection = async () => {
|
||||||
|
setTestingConnection(true);
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/auth-provider/test", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
providerId: authForm.providerId,
|
||||||
|
issuerUrl: authForm.issuerUrl,
|
||||||
|
clientId: authForm.clientId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setTestResult(data);
|
||||||
|
} catch {
|
||||||
|
setTestResult({ ok: false, error: "Network error. Please try again." });
|
||||||
|
} finally {
|
||||||
|
setTestingConnection(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAuthSave = async () => {
|
||||||
|
setAuthSaving(true);
|
||||||
|
setAuthMessage(null);
|
||||||
|
try {
|
||||||
|
const payload: Record<string, string> = {
|
||||||
|
providerId: authForm.providerId,
|
||||||
|
displayName: authForm.displayName,
|
||||||
|
issuerUrl: authForm.issuerUrl,
|
||||||
|
clientId: authForm.clientId,
|
||||||
|
scopes: authForm.scopes,
|
||||||
|
};
|
||||||
|
if (authForm.internalBaseUrl) {
|
||||||
|
payload.internalBaseUrl = authForm.internalBaseUrl;
|
||||||
|
}
|
||||||
|
// Only send clientSecret if user changed it from the redacted value
|
||||||
|
if (authSecretTouched) {
|
||||||
|
payload.clientSecret = authForm.clientSecret;
|
||||||
|
}
|
||||||
|
const res = await fetch("/api/admin/auth-provider", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => null);
|
||||||
|
throw new Error(err?.error ?? "Failed to save auth provider");
|
||||||
|
}
|
||||||
|
const saved = await res.json() as AuthProviderConfig;
|
||||||
|
setAuthConfig(saved);
|
||||||
|
setAuthForm({
|
||||||
|
providerId: saved.providerId,
|
||||||
|
displayName: saved.displayName,
|
||||||
|
issuerUrl: saved.issuerUrl,
|
||||||
|
internalBaseUrl: saved.internalBaseUrl ?? "",
|
||||||
|
clientId: saved.clientId,
|
||||||
|
clientSecret: saved.clientSecret,
|
||||||
|
scopes: saved.scopes,
|
||||||
|
});
|
||||||
|
setAuthSecretTouched(false);
|
||||||
|
setAuthMessage({ type: "success", text: "Auth provider saved." });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setAuthMessage({ type: "error", text: err instanceof Error ? err.message : "Save failed" });
|
||||||
|
} finally {
|
||||||
|
setAuthSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetToEnvDefaults = async () => {
|
||||||
|
if (!confirmReset) {
|
||||||
|
setConfirmReset(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setConfirmReset(false);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/auth-provider", { method: "DELETE" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => null);
|
||||||
|
throw new Error(err?.error ?? "Failed to reset auth provider");
|
||||||
|
}
|
||||||
|
setAuthConfig(null);
|
||||||
|
setAuthForm({
|
||||||
|
providerId: "authentik",
|
||||||
|
displayName: "",
|
||||||
|
issuerUrl: "",
|
||||||
|
internalBaseUrl: "",
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
scopes: "openid profile email",
|
||||||
|
});
|
||||||
|
setAuthSecretTouched(false);
|
||||||
|
setAuthMessage({ type: "success", text: "Auth provider reset to environment defaults." });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setAuthMessage({ type: "error", text: err instanceof Error ? err.message : "Reset failed" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!loaded) return <p>Loading settings...</p>;
|
if (!loaded) return <p>Loading settings...</p>;
|
||||||
|
|
||||||
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
|
const logoSrc = form.logoUrl ?? (form.logoBase64 && form.logoMimeType ? `data:${form.logoMimeType};base64,${form.logoBase64}` : null);
|
||||||
@@ -385,6 +567,260 @@ export function SettingsPage() {
|
|||||||
>
|
>
|
||||||
{saving ? "Saving..." : "Save Changes"}
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Auth Provider Section — super users only */}
|
||||||
|
{currentUser?.isSuperUser && (
|
||||||
|
<>
|
||||||
|
<hr style={{ margin: "2rem 0", border: "none", borderTop: "1px solid #e5e7eb" }} />
|
||||||
|
<h2>Authentication Provider</h2>
|
||||||
|
<p style={{ color: "#6b7280", marginBottom: "1rem" }}>
|
||||||
|
Configure the SSO provider for sign-in. Changes require a service restart.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Warning banner */}
|
||||||
|
<div style={{
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: "1rem",
|
||||||
|
fontSize: 13,
|
||||||
|
background: "#fef3c7",
|
||||||
|
color: "#92400e",
|
||||||
|
border: "1px solid #fde68a",
|
||||||
|
}}>
|
||||||
|
⚠️ Changing auth settings will require a service restart. Active sessions will be preserved.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Environment config banner */}
|
||||||
|
{!authConfig && authLoaded && (
|
||||||
|
<div style={{
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: "1rem",
|
||||||
|
fontSize: 13,
|
||||||
|
background: "#eff6ff",
|
||||||
|
color: "#1e40af",
|
||||||
|
border: "1px solid #bfdbfe",
|
||||||
|
}}>
|
||||||
|
Currently using environment configuration (no DB config set).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!authLoaded && <p style={{ color: "#6b7280", fontSize: 14 }}>Loading auth provider...</p>}
|
||||||
|
|
||||||
|
{authLoaded && (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.875rem", marginBottom: "1rem" }}>
|
||||||
|
{/* Provider ID */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Provider ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={authForm.providerId}
|
||||||
|
onChange={(e) => setAuthForm((f) => ({ ...f, providerId: e.target.value }))}
|
||||||
|
placeholder="e.g. authentik, okta"
|
||||||
|
style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display Name */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Display Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={authForm.displayName}
|
||||||
|
onChange={(e) => setAuthForm((f) => ({ ...f, displayName: e.target.value }))}
|
||||||
|
placeholder="e.g. Company SSO"
|
||||||
|
style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issuer URL */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>
|
||||||
|
Issuer URL
|
||||||
|
</label>
|
||||||
|
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={authForm.issuerUrl}
|
||||||
|
onChange={(e) => setAuthForm((f) => ({ ...f, issuerUrl: e.target.value }))}
|
||||||
|
placeholder="https://your-idp.example.com"
|
||||||
|
style={{ flex: 1, padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={testingConnection || !authForm.issuerUrl.trim() || !authForm.clientId.trim()}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 0.875rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
background: "#fff",
|
||||||
|
cursor: testingConnection || !authForm.issuerUrl.trim() || !authForm.clientId.trim() ? "not-allowed" : "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
opacity: testingConnection || !authForm.issuerUrl.trim() || !authForm.clientId.trim() ? 0.6 : 1,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{testingConnection ? "Testing..." : "Test Connection"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Test result */}
|
||||||
|
{testResult && (
|
||||||
|
<div style={{
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
fontSize: 13,
|
||||||
|
background: testResult.ok ? "#ecfdf5" : "#fef2f2",
|
||||||
|
color: testResult.ok ? "#065f46" : "#991b1b",
|
||||||
|
border: `1px solid ${testResult.ok ? "#a7f3d0" : "#fecaca"}`,
|
||||||
|
}}>
|
||||||
|
{testResult.ok ? "✓ Connection successful" : `✗ ${testResult.error}`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Internal Base URL — collapsible */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowInternalBaseUrl((v) => !v)}
|
||||||
|
style={{
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
padding: 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: 13,
|
||||||
|
color: "#4b5563",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showInternalBaseUrl ? "▾" : "▸"} Internal Base URL
|
||||||
|
<span style={{ fontSize: 11, color: "#9ca3af", fontWeight: 400 }}>(optional — hairpin NAT)</span>
|
||||||
|
</button>
|
||||||
|
{showInternalBaseUrl && (
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={authForm.internalBaseUrl}
|
||||||
|
onChange={(e) => setAuthForm((f) => ({ ...f, internalBaseUrl: e.target.value }))}
|
||||||
|
placeholder="http://host.docker.internal:9080"
|
||||||
|
style={{ marginTop: 4, width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Client ID */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Client ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={authForm.clientId}
|
||||||
|
onChange={(e) => setAuthForm((f) => ({ ...f, clientId: e.target.value }))}
|
||||||
|
style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Client Secret */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Client Secret</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={authSecretTouched ? authForm.clientSecret : (authForm.clientSecret === REDACTED ? "" : authForm.clientSecret)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAuthSecretTouched(true);
|
||||||
|
setAuthForm((f) => ({ ...f, clientSecret: e.target.value }));
|
||||||
|
}}
|
||||||
|
placeholder={authConfig ? "(unchanged)" : "Required"}
|
||||||
|
style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
{authConfig && !authSecretTouched && (
|
||||||
|
<p style={{ fontSize: 12, color: "#9ca3af", marginTop: 2 }}>Leave blank to keep existing secret.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scopes */}
|
||||||
|
<div>
|
||||||
|
<label style={{ display: "block", fontWeight: 600, marginBottom: 4, fontSize: 13 }}>Scopes</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={authForm.scopes}
|
||||||
|
onChange={(e) => setAuthForm((f) => ({ ...f, scopes: e.target.value }))}
|
||||||
|
style={{ width: "100%", padding: "0.5rem 0.75rem", border: "1px solid #d1d5db", borderRadius: 6, fontSize: 14 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auth messages */}
|
||||||
|
{authMessage && (
|
||||||
|
<div style={{
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
marginBottom: "1rem",
|
||||||
|
fontSize: 14,
|
||||||
|
background: authMessage.type === "success" ? "#ecfdf5" : "#fef2f2",
|
||||||
|
color: authMessage.type === "success" ? "#065f46" : "#991b1b",
|
||||||
|
border: `1px solid ${authMessage.type === "success" ? "#a7f3d0" : "#fecaca"}`,
|
||||||
|
}}>
|
||||||
|
{authMessage.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div style={{ display: "flex", gap: "0.75rem", flexWrap: "wrap" }}>
|
||||||
|
<button
|
||||||
|
onClick={handleAuthSave}
|
||||||
|
disabled={authSaving || !authForm.providerId.trim() || !authForm.issuerUrl.trim() || !authForm.clientId.trim()}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1.25rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "none",
|
||||||
|
background: "#4f8a6f",
|
||||||
|
color: "#fff",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: authSaving || !authForm.providerId.trim() || !authForm.issuerUrl.trim() || !authForm.clientId.trim() ? "not-allowed" : "pointer",
|
||||||
|
opacity: authSaving ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{authSaving ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleResetToEnvDefaults}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 1.25rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: confirmReset ? "1px solid #dc2626" : "1px solid #d1d5db",
|
||||||
|
background: confirmReset ? "#fef2f2" : "#fff",
|
||||||
|
color: confirmReset ? "#dc2626" : "#6b7280",
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmReset ? "Confirm Reset to Env Defaults?" : "Reset to Environment Defaults"}
|
||||||
|
</button>
|
||||||
|
{confirmReset && (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmReset(false)}
|
||||||
|
style={{
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
borderRadius: 6,
|
||||||
|
border: "1px solid #d1d5db",
|
||||||
|
background: "#fff",
|
||||||
|
color: "#6b7280",
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user