feat: OIDC-Anmeldebutton frei beschriftbar, Standard Mit SSO anmelden
This commit is contained in:
1 parent
ef8662130c
commit
076a6b0cd4
6 files changed
+51
-13
No files matched your search
@@ -1,11 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isOidcConfigured } from "@/lib/oidc";
|
||||
import { getOidcSettings } from "@/lib/integration-settings";
|
||||
import { isPasswordLoginAllowed } from "@/lib/authz";
|
||||
|
||||
/** Öffentlich: zeigt an, ob SSO angeboten wird und ob Passwort-Login erlaubt ist. */
|
||||
/** Öffentlich: zeigt an, ob SSO angeboten wird, mit welcher Button-Beschriftung, und ob Passwort-Login erlaubt ist. */
|
||||
export async function GET() {
|
||||
const enabled = await isOidcConfigured();
|
||||
const { buttonLabel } = enabled ? await getOidcSettings() : { buttonLabel: "Mit SSO anmelden" };
|
||||
return NextResponse.json({
|
||||
oidcEnabled: await isOidcConfigured(),
|
||||
oidcEnabled: enabled,
|
||||
oidcButtonLabel: buttonLabel,
|
||||
passwordAllowed: isPasswordLoginAllowed(),
|
||||
});
|
||||
}
|
||||
+5
-1
@@ -9,6 +9,7 @@ export default function LoginPage() {
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [oidcEnabled, setOidcEnabled] = useState(false);
|
||||
const [oidcLabel, setOidcLabel] = useState("Mit SSO anmelden");
|
||||
const [passwordAllowed, setPasswordAllowed] = useState(true);
|
||||
const [ssoError, setSsoError] = useState("");
|
||||
|
||||
@@ -25,6 +26,9 @@ export default function LoginPage() {
|
||||
.then((s) => {
|
||||
if (!s) return;
|
||||
setOidcEnabled(!!s.oidcEnabled);
|
||||
if (typeof s.oidcButtonLabel === "string" && s.oidcButtonLabel.trim() !== "") {
|
||||
setOidcLabel(s.oidcButtonLabel);
|
||||
}
|
||||
setPasswordAllowed(s.passwordAllowed !== false);
|
||||
})
|
||||
.catch(() => {});
|
||||
@@ -80,7 +84,7 @@ export default function LoginPage() {
|
||||
href="/api/auth/oidc/login"
|
||||
className="mb-5 flex h-[47px] items-center justify-center rounded-field border border-line bg-white text-sm font-semibold transition-colors hover:border-brand-600 hover:text-brand-700"
|
||||
>
|
||||
Mit SSO anmelden
|
||||
{oidcLabel}
|
||||
</a>
|
||||
)}
|
||||
{ssoError && (
|
||||
|
||||
@@ -7,7 +7,7 @@ export type IntegrationStatus = {
|
||||
smtp: { host: string; port: number; user: string; from: string; passGesetzt: boolean };
|
||||
telegram: { botTokenGesetzt: boolean };
|
||||
logo: { secretGesetzt: boolean; publishableKey: string };
|
||||
oidc: { issuer: string; clientId: string; secretGesetzt: boolean; signingAlg: string };
|
||||
oidc: { issuer: string; clientId: string; secretGesetzt: boolean; signingAlg: string; buttonLabel: string };
|
||||
verschluesselungBereit: boolean;
|
||||
};
|
||||
|
||||
@@ -207,6 +207,7 @@ export function OidcSection({ status, onSaved }: { status: IntegrationStatus; on
|
||||
const [clientId, setClientId] = useState(status.oidc.clientId);
|
||||
const [secret, setSecret] = useState("");
|
||||
const [signingAlg, setSigningAlg] = useState(status.oidc.signingAlg || "RS256");
|
||||
const [buttonLabel, setButtonLabel] = useState(status.oidc.buttonLabel || "");
|
||||
return (
|
||||
<Section title="SSO (OIDC)">
|
||||
<p className="text-[13px] text-muted">Generischer OIDC-Anbieter, z. B. Authentik oder Keycloak. Nach dem Speichern erscheint auf der Anmeldeseite „Mit SSO anmelden“.</p>
|
||||
@@ -262,13 +263,26 @@ export function OidcSection({ status, onSaved }: { status: IntegrationStatus; on
|
||||
</select>
|
||||
<p className="mt-1 text-[13px] text-muted">Muss zum Anbieter passen (dort: id_token_signed_response_alg).</p>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="oidc-label" className="block text-[13px] font-semibold">Beschriftung des Anmelde-Buttons</label>
|
||||
<input
|
||||
id="oidc-label"
|
||||
value={buttonLabel}
|
||||
onChange={(e) => setButtonLabel(e.target.value)}
|
||||
autoComplete="off"
|
||||
maxLength={60}
|
||||
placeholder="Mit SSO anmelden"
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="mt-1 text-[13px] text-muted">Leer lassen für „Mit SSO anmelden“.</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setNotice("");
|
||||
try {
|
||||
const s = await saveSection("oidc", { oidc_issuer: issuer, oidc_client_id: clientId, oidc_client_secret: secret, oidc_signing_alg: signingAlg });
|
||||
const s = await saveSection("oidc", { oidc_issuer: issuer, oidc_client_id: clientId, oidc_client_secret: secret, oidc_signing_alg: signingAlg, oidc_button_label: buttonLabel });
|
||||
setSecret("");
|
||||
onSaved(s);
|
||||
setNotice("OIDC-Einstellungen gespeichert.");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getDb } from "./db";
|
||||
import { canEncrypt, decryptSecret, encryptSecret } from "./settings-crypto";
|
||||
import { normalizeSigningAlg, OIDC_SIGNING_ALGS } from "./oidc-algs";
|
||||
import { normalizeSigningAlg, normalizeButtonLabel, OIDC_SIGNING_ALGS, DEFAULT_OIDC_BUTTON_LABEL } from "./oidc-algs";
|
||||
|
||||
export const INTEGRATION_SECTIONS = ["smtp", "telegram", "logo", "oidc"] as const;
|
||||
export type IntegrationSection = (typeof INTEGRATION_SECTIONS)[number];
|
||||
@@ -25,24 +25,26 @@ const ENV_FALLBACK: Record<string, string> = {
|
||||
oidc_client_id: "OIDC_CLIENT_ID",
|
||||
oidc_client_secret: "OIDC_CLIENT_SECRET",
|
||||
oidc_signing_alg: "OIDC_SIGNING_ALG",
|
||||
oidc_button_label: "OIDC_BUTTON_LABEL",
|
||||
};
|
||||
|
||||
export type SmtpSettings = { host: string; port: number; user: string; pass: string; from: string };
|
||||
export type TelegramSettings = { botToken: string };
|
||||
export type LogoSettings = { secretKey: string; publishableKey: string };
|
||||
export type OidcSettings = { issuer: string; clientId: string; clientSecret: string; signingAlg: string };
|
||||
export type OidcSettings = { issuer: string; clientId: string; clientSecret: string; signingAlg: string; buttonLabel: string };
|
||||
|
||||
export async function getOidcSettings(): Promise<OidcSettings> {
|
||||
const [issuer, clientId, clientSecret, signingAlg] = await Promise.all([
|
||||
const [issuer, clientId, clientSecret, signingAlg, buttonLabel] = await Promise.all([
|
||||
resolveValue("oidc_issuer"),
|
||||
resolveValue("oidc_client_id"),
|
||||
resolveValue("oidc_client_secret"),
|
||||
resolveValue("oidc_signing_alg"),
|
||||
resolveValue("oidc_button_label"),
|
||||
]);
|
||||
return { issuer: issuer.replace(/\/$/, ""), clientId, clientSecret, signingAlg: normalizeSigningAlg(signingAlg) };
|
||||
return { issuer: issuer.replace(/\/$/, ""), clientId, clientSecret, signingAlg: normalizeSigningAlg(signingAlg), buttonLabel: normalizeButtonLabel(buttonLabel) };
|
||||
}
|
||||
|
||||
export { OIDC_SIGNING_ALGS, normalizeSigningAlg };
|
||||
export { OIDC_SIGNING_ALGS, normalizeSigningAlg, normalizeButtonLabel, DEFAULT_OIDC_BUTTON_LABEL };
|
||||
|
||||
async function readStored(key: string): Promise<string | null> {
|
||||
const db = getDb();
|
||||
@@ -107,14 +109,14 @@ export async function getIntegrationStatus() {
|
||||
|
||||
async function getOidcStatus() {
|
||||
const s = await getOidcSettings();
|
||||
return { issuer: s.issuer, clientId: s.clientId, secretGesetzt: s.clientSecret !== "", signingAlg: s.signingAlg };
|
||||
return { issuer: s.issuer, clientId: s.clientId, secretGesetzt: s.clientSecret !== "", signingAlg: s.signingAlg, buttonLabel: s.buttonLabel };
|
||||
}
|
||||
|
||||
const SECTION_FIELDS: Record<IntegrationSection, string[]> = {
|
||||
smtp: ["smtp_host", "smtp_port", "smtp_user", "smtp_pass", "mail_from"],
|
||||
telegram: ["telegram_bot_token"],
|
||||
logo: ["logo_dev_secret_key", "logo_dev_publishable_key"],
|
||||
oidc: ["oidc_issuer", "oidc_client_id", "oidc_client_secret", "oidc_signing_alg"],
|
||||
oidc: ["oidc_issuer", "oidc_client_id", "oidc_client_secret", "oidc_signing_alg", "oidc_button_label"],
|
||||
};
|
||||
|
||||
export function validateIntegrationSection(section: string, values: Record<string, unknown>): string[] {
|
||||
|
||||
@@ -5,3 +5,11 @@ export function normalizeSigningAlg(raw: string | null | undefined): string {
|
||||
const alg = (raw ?? "").trim().toUpperCase();
|
||||
return (OIDC_SIGNING_ALGS as readonly string[]).includes(alg) ? alg : "RS256";
|
||||
}
|
||||
|
||||
/** Anmelde-Button-Beschriftung: leer wird Standard, längere Texte auf 60 Zeichen gekürzt. */
|
||||
export const DEFAULT_OIDC_BUTTON_LABEL = "Mit SSO anmelden";
|
||||
|
||||
export function normalizeButtonLabel(raw: string | null | undefined): string {
|
||||
const label = (raw ?? "").trim().slice(0, 60);
|
||||
return label === "" ? DEFAULT_OIDC_BUTTON_LABEL : label;
|
||||
}
|
||||
+7
-1
@@ -4,7 +4,7 @@ import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { createServer, type Server } from "https";
|
||||
import { generateKeyPair, exportJWK, SignJWT, type KeyLike } from "jose";
|
||||
import { normalizeSigningAlg } from "../lib/oidc-algs";
|
||||
import { normalizeSigningAlg, normalizeButtonLabel, DEFAULT_OIDC_BUTTON_LABEL } from "../lib/oidc-algs";
|
||||
import { buildLoginUrl, verifyCallbackCode, type OidcDiscovery } from "../lib/oidc";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -62,6 +62,12 @@ describe("normalizeSigningAlg", () => {
|
||||
expect(normalizeSigningAlg("ES384")).toBe("ES384");
|
||||
expect(normalizeSigningAlg("ps512")).toBe("PS512");
|
||||
});
|
||||
it("leere Beschriftung fällt auf Standard zurück, lange wird gekürzt", () => {
|
||||
expect(normalizeButtonLabel(undefined)).toBe(DEFAULT_OIDC_BUTTON_LABEL);
|
||||
expect(normalizeButtonLabel(" ")).toBe(DEFAULT_OIDC_BUTTON_LABEL);
|
||||
expect(normalizeButtonLabel("Mit Keycloak anmelden")).toBe("Mit Keycloak anmelden");
|
||||
expect(normalizeButtonLabel("x".repeat(100))).toHaveLength(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildLoginUrl", () => {
|
||||
|
||||
Reference in new issue
Block a user