mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move sso and ldap logins into plugins (C2)
This commit is contained in:
1 parent
5c18435105
commit
925da4a661
223 files changed
+25778
-6820
No files matched your search
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* The allowed-users list an external login method passes with its identity:
|
||||
* one pattern per line or comma, "*" for anyone, "@example.com" for a
|
||||
* domain, "*" as a wildcard inside a pattern, or an exact name. Empty allows
|
||||
* everyone.
|
||||
*/
|
||||
export function isExternalUserAllowed(
|
||||
allowedUsers: string,
|
||||
identifier: string,
|
||||
email?: string,
|
||||
): boolean {
|
||||
if (!allowedUsers || !allowedUsers.trim()) return true;
|
||||
const patterns = allowedUsers
|
||||
.split(/[\n,]/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (patterns.length === 0) return true;
|
||||
|
||||
const values = [
|
||||
identifier,
|
||||
...(email && email !== identifier ? [email] : []),
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
if (pattern === "*") return true;
|
||||
if (pattern.includes("*")) {
|
||||
const escaped = pattern
|
||||
.toLowerCase()
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*");
|
||||
const regex = new RegExp(`^${escaped}$`);
|
||||
if (values.some((v) => v && regex.test(v.toLowerCase()))) return true;
|
||||
continue;
|
||||
}
|
||||
for (const value of values) {
|
||||
if (!value) continue;
|
||||
if (pattern.toLowerCase().startsWith("@")) {
|
||||
if (value.toLowerCase().endsWith(pattern.toLowerCase())) return true;
|
||||
} else {
|
||||
if (value.toLowerCase() === pattern.toLowerCase()) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { getCurrentSettingValue } from "../database/repositories/factory.js";
|
||||
import { authLogger } from "../utils/logger.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../utils/trusted-proxy-auth.js";
|
||||
import { registerPasswordLoginMethod } from "./builtin-login-methods.js";
|
||||
import { registerLegacyLoginProviders } from "./legacy-providers.js";
|
||||
import { listLoginMethods } from "./registry.js";
|
||||
import { LoginMethodError } from "./types.js";
|
||||
|
||||
@@ -17,7 +16,6 @@ export function ensureCoreLoginProviders(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerPasswordLoginMethod();
|
||||
registerLegacyLoginProviders();
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
|
||||
@@ -16,7 +16,6 @@ import type {
|
||||
SshAuthProvider,
|
||||
SshConnectHost,
|
||||
} from "../hosts/connect/types.js";
|
||||
import { registerLoginMethod, type LoginMethod } from "./registry.js";
|
||||
|
||||
const OPKSSH_REQUIRED_MESSAGE =
|
||||
"OPKSSH authentication required. Please open a Terminal connection to this host first to complete browser-based authentication. Your session will be cached for 24 hours.";
|
||||
@@ -263,77 +262,7 @@ export function registerLegacySshAuthProviders(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// Login methods and second factors -------------------------------------------
|
||||
|
||||
async function listPublicProviders(types: string[]) {
|
||||
const { listPublicSsoProviders } =
|
||||
await import("../database/routes/sso-provider-routes.js");
|
||||
return (await listPublicSsoProviders()).filter((provider) =>
|
||||
types.includes(provider.type),
|
||||
);
|
||||
}
|
||||
|
||||
const oidcLoginMethod: LoginMethod = {
|
||||
id: "oidc",
|
||||
pluginId: "core",
|
||||
labelKey: "auth.loginWithSso",
|
||||
icon: "key-round",
|
||||
kind: "redirect",
|
||||
external: true,
|
||||
describe: async () =>
|
||||
(await listPublicProviders(["oidc", "github", "google"])).map(
|
||||
(provider) => ({
|
||||
id: String(provider.id),
|
||||
label: provider.name,
|
||||
enabled: true,
|
||||
}),
|
||||
),
|
||||
start: async (request, instanceId) => {
|
||||
const { startOidcLogin } = await import("./legacy/oidc-login.js");
|
||||
// Instance "0" is the provider synthesized from environment variables.
|
||||
const result = await startOidcLogin(request as never, {
|
||||
providerId: instanceId && instanceId !== "0" ? instanceId : undefined,
|
||||
});
|
||||
return { redirectUrl: result.auth_url };
|
||||
},
|
||||
callback: async (request) => {
|
||||
const { handleOidcCallback } = await import("./legacy/oidc-login.js");
|
||||
return handleOidcCallback(request as never);
|
||||
},
|
||||
};
|
||||
|
||||
const ldapLoginMethod: LoginMethod = {
|
||||
id: "ldap",
|
||||
pluginId: "core",
|
||||
labelKey: "auth.loginWithLdap",
|
||||
icon: "server",
|
||||
kind: "form",
|
||||
external: true,
|
||||
describe: async () =>
|
||||
(await listPublicProviders(["ldap"])).map((provider) => ({
|
||||
id: String(provider.id),
|
||||
label: provider.name,
|
||||
enabled: true,
|
||||
})),
|
||||
verify: async (request, instanceId) => {
|
||||
const { verifyLdapLogin } = await import("./legacy/ldap-login.js");
|
||||
const body = { ...request.body };
|
||||
if (instanceId && !body.providerId) body.providerId = Number(instanceId);
|
||||
return verifyLdapLogin({ body, ip: request.ip });
|
||||
},
|
||||
};
|
||||
|
||||
let loginRegistered = false;
|
||||
|
||||
export function registerLegacyLoginProviders(): void {
|
||||
if (loginRegistered) return;
|
||||
loginRegistered = true;
|
||||
registerLoginMethod(oidcLoginMethod);
|
||||
registerLoginMethod(ldapLoginMethod);
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetLegacyProvidersForTests(): void {
|
||||
sshRegistered = false;
|
||||
loginRegistered = false;
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* LDAP sign-in as a form login method. Moved out of the LDAP route so the
|
||||
* pipeline issues the session; Phase C moves it into the LDAP plugin.
|
||||
*/
|
||||
|
||||
import ldap from "ldapjs";
|
||||
import type { LDAPProviderConfig } from "../../../types/index.js";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { loginRateLimiter } from "../../utils/login-rate-limiter.js";
|
||||
import { createCurrentSsoProviderRepository } from "../../database/repositories/factory.js";
|
||||
import { loadProviderConfig } from "../../database/routes/user-oidc-utils.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "../types.js";
|
||||
|
||||
export function ldapEscapeFilter(value: string): string {
|
||||
return value.replace(
|
||||
/[\\*()\x00]/g,
|
||||
(c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`,
|
||||
);
|
||||
}
|
||||
|
||||
function createLDAPClient(
|
||||
host: string,
|
||||
port: number,
|
||||
useTLS: boolean,
|
||||
): ldap.Client {
|
||||
const url = `${useTLS ? "ldaps" : "ldap"}://${host}:${port}`;
|
||||
return ldap.createClient({
|
||||
url,
|
||||
tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function ldapBind(
|
||||
client: ldap.Client,
|
||||
dn: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.bind(dn, password, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ldapSearch(
|
||||
client: ldap.Client,
|
||||
base: string,
|
||||
filter: string,
|
||||
attributes: string[],
|
||||
): Promise<ldap.SearchEntry[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const entries: ldap.SearchEntry[] = [];
|
||||
client.search(base, { filter, attributes, scope: "sub" }, (err, res) => {
|
||||
if (err) return reject(err);
|
||||
res.on("searchEntry", (entry) => entries.push(entry));
|
||||
res.on("error", reject);
|
||||
res.on("end", () => resolve(entries));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ldapUnbind(client: ldap.Client): void {
|
||||
try {
|
||||
client.unbind();
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
function firstValue(entry: ldap.SearchEntry, key: string): string {
|
||||
const attr = entry.attributes.find((a) => a.type === key);
|
||||
if (!attr) return "";
|
||||
return Array.isArray(attr.values) ? attr.values[0] : String(attr.values);
|
||||
}
|
||||
|
||||
export async function verifyLdapLogin(request: {
|
||||
body: Record<string, unknown>;
|
||||
ip?: string;
|
||||
}): Promise<VerifiedIdentity> {
|
||||
const { providerId, username, password, rememberMe } = request.body as {
|
||||
providerId?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
rememberMe?: boolean;
|
||||
};
|
||||
|
||||
if (!providerId || !username || !password) {
|
||||
throw new LoginMethodError(
|
||||
"providerId, username, and password are required",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
// Same limits as password login: an LDAP bind is a password guess.
|
||||
const clientIp = request.ip || "unknown";
|
||||
const rateLimitKey = `ldap:${providerId}:${username}`;
|
||||
const lockStatus = loginRateLimiter.isLocked(clientIp, rateLimitKey);
|
||||
if (lockStatus.locked) {
|
||||
const error = new LoginMethodError(
|
||||
"Too many login attempts. Please try again later.",
|
||||
429,
|
||||
);
|
||||
Object.assign(error, { remainingTime: lockStatus.remainingTime });
|
||||
throw error;
|
||||
}
|
||||
|
||||
const provider =
|
||||
await createCurrentSsoProviderRepository().findById(providerId);
|
||||
if (!provider || provider.type !== "ldap" || !provider.enabled) {
|
||||
throw new LoginMethodError("LDAP provider not found", 404);
|
||||
}
|
||||
|
||||
const providerResult = await loadProviderConfig(providerId);
|
||||
if (!providerResult) {
|
||||
throw new LoginMethodError("LDAP provider not found", 404);
|
||||
}
|
||||
const config = providerResult.config as unknown as LDAPProviderConfig;
|
||||
if (
|
||||
!config.host ||
|
||||
!config.bindDN ||
|
||||
!config.userSearchBase ||
|
||||
!config.userSearchFilter
|
||||
) {
|
||||
throw new LoginMethodError("LDAP provider is misconfigured", 500);
|
||||
}
|
||||
|
||||
const serviceClient = createLDAPClient(
|
||||
config.host,
|
||||
config.port || 389,
|
||||
config.useTLS || false,
|
||||
);
|
||||
try {
|
||||
await ldapBind(serviceClient, config.bindDN, config.bindPassword);
|
||||
|
||||
const filter = config.userSearchFilter.replace(
|
||||
/\{\{username\}\}/g,
|
||||
ldapEscapeFilter(username),
|
||||
);
|
||||
const entries = await ldapSearch(
|
||||
serviceClient,
|
||||
config.userSearchBase,
|
||||
filter,
|
||||
[
|
||||
config.usernameAttribute || "uid",
|
||||
config.displayNameAttribute || "cn",
|
||||
"mail",
|
||||
"email",
|
||||
],
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
loginRateLimiter.recordFailedAttempt(clientIp, rateLimitKey);
|
||||
authLogger.warn("LDAP user not found", {
|
||||
operation: "ldap_login",
|
||||
username,
|
||||
});
|
||||
throw new LoginMethodError("Invalid username or password", 401);
|
||||
}
|
||||
|
||||
const userEntry = entries[0];
|
||||
const userDN = userEntry.dn.toString();
|
||||
const ldapIdentifier =
|
||||
firstValue(userEntry, config.usernameAttribute || "uid") || username;
|
||||
const displayName =
|
||||
firstValue(userEntry, config.displayNameAttribute || "cn") || username;
|
||||
const email =
|
||||
firstValue(userEntry, "mail") || firstValue(userEntry, "email") || "";
|
||||
|
||||
const userClient = createLDAPClient(
|
||||
config.host,
|
||||
config.port || 389,
|
||||
config.useTLS || false,
|
||||
);
|
||||
try {
|
||||
await ldapBind(userClient, userDN, password);
|
||||
} catch {
|
||||
loginRateLimiter.recordFailedAttempt(clientIp, rateLimitKey);
|
||||
authLogger.warn("LDAP bind failed - wrong password", {
|
||||
operation: "ldap_login",
|
||||
ldapIdentifier,
|
||||
});
|
||||
throw new LoginMethodError("Invalid username or password", 401);
|
||||
} finally {
|
||||
ldapUnbind(userClient);
|
||||
}
|
||||
|
||||
let isAdmin: boolean | undefined;
|
||||
if (config.adminGroup && config.groupSearchBase) {
|
||||
isAdmin = false;
|
||||
try {
|
||||
const groupEntries = await ldapSearch(
|
||||
serviceClient,
|
||||
config.groupSearchBase,
|
||||
`(member=${ldapEscapeFilter(userDN)})`,
|
||||
["cn", "dn"],
|
||||
);
|
||||
isAdmin = groupEntries.some(
|
||||
(group) =>
|
||||
firstValue(group, "cn") === config.adminGroup ||
|
||||
group.dn.toString() === config.adminGroup,
|
||||
);
|
||||
} catch (groupErr) {
|
||||
authLogger.warn("LDAP group check failed", {
|
||||
operation: "ldap_group_check",
|
||||
error: groupErr,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "external",
|
||||
provider: String(providerId),
|
||||
subject: ldapIdentifier,
|
||||
email: email || null,
|
||||
name: displayName,
|
||||
isAdmin,
|
||||
allowedUsers: config.allowedUsers ?? null,
|
||||
legacyIdentifier: `ldap:${providerId}:${ldapIdentifier}`,
|
||||
ssoProviderId: providerId,
|
||||
rememberMe: !!rememberMe,
|
||||
rateLimitUsername: rateLimitKey,
|
||||
};
|
||||
} finally {
|
||||
ldapUnbind(serviceClient);
|
||||
}
|
||||
}
|
||||
@@ -1,518 +0,0 @@
|
||||
/**
|
||||
* OIDC, GitHub and Google sign-in, moved out of the users routes so they can
|
||||
* register as a login method. Phase C moves this into the OIDC plugin.
|
||||
*
|
||||
* start builds the provider's authorize URL; callback turns the provider's
|
||||
* answer into a verified identity. Neither touches sessions.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import type { Request } from "express";
|
||||
import { nanoid } from "nanoid";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { getRequestOriginWithForceHTTPS } from "../../utils/request-origin.js";
|
||||
import { getDesktopOidcCallbackUrl } from "../../utils/oidc-desktop-callback.js";
|
||||
import { createCurrentSettingsRepository } from "../../database/repositories/factory.js";
|
||||
import {
|
||||
OIDCTokenFormatError,
|
||||
buildFetchOptions,
|
||||
extractOidcGroupsFromSources,
|
||||
loadProviderConfig,
|
||||
parseOidcRoleMap,
|
||||
resolveOidcMappedRoles,
|
||||
verifyOIDCToken,
|
||||
} from "../../database/routes/user-oidc-utils.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "../types.js";
|
||||
|
||||
/** An error raised after the return address is known redirects there. */
|
||||
export class RedirectLoginError extends LoginMethodError {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly returnTo: string,
|
||||
status = 400,
|
||||
code?: string,
|
||||
) {
|
||||
super(message, status, code);
|
||||
this.name = "RedirectLoginError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider id used for OIDC identities that came from env configuration. */
|
||||
export const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
|
||||
|
||||
function nonEmpty(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
// RFC 7636 PKCE: 43-128 char unreserved-character string.
|
||||
function generatePkceCodeVerifier(): string {
|
||||
return crypto.randomBytes(64).toString("base64url");
|
||||
}
|
||||
|
||||
function generatePkceCodeChallenge(verifier: string): string {
|
||||
return crypto.createHash("sha256").update(verifier).digest("base64url");
|
||||
}
|
||||
|
||||
export async function deleteOIDCStateSettings(state: string): Promise<void> {
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
await settingsRepository.delete(`oidc_state_${state}`);
|
||||
await settingsRepository.delete(`oidc_backend_callback_${state}`);
|
||||
await settingsRepository.delete(`oidc_frontend_origin_${state}`);
|
||||
await settingsRepository.delete(`oidc_remember_me_${state}`);
|
||||
await settingsRepository.delete(`oidc_provider_${state}`);
|
||||
await settingsRepository.delete(`oidc_pkce_verifier_${state}`);
|
||||
}
|
||||
|
||||
export async function startOidcLogin(
|
||||
req: Request,
|
||||
overrides: { providerId?: string | null } = {},
|
||||
): Promise<{ auth_url: string; state: string; nonce: string }> {
|
||||
const { rememberMe, desktopCallbackPort, appCallbackUrl } = req.query;
|
||||
const providerIdStr = overrides.providerId ?? req.query.providerId;
|
||||
const origin = getRequestOriginWithForceHTTPS(req);
|
||||
const basePath = (process.env.BASE_PATH || "").replace(/\/+$/, "");
|
||||
const backendCallbackUri = `${origin}${basePath}/users/oidc/callback`;
|
||||
|
||||
const resolvedProviderId = providerIdStr
|
||||
? parseInt(providerIdStr as string, 10)
|
||||
: null;
|
||||
const providerResult = await loadProviderConfig(
|
||||
resolvedProviderId || undefined,
|
||||
);
|
||||
if (!providerResult) {
|
||||
throw new LoginMethodError("OIDC not configured", 404);
|
||||
}
|
||||
const { config, providerDbId } = providerResult;
|
||||
const state = nanoid();
|
||||
const nonce = nanoid();
|
||||
|
||||
const referer = req.get("Referer");
|
||||
let frontendOrigin: string;
|
||||
if (desktopCallbackPort) {
|
||||
frontendOrigin = getDesktopOidcCallbackUrl(desktopCallbackPort);
|
||||
if (!frontendOrigin) {
|
||||
throw new LoginMethodError("Invalid desktop callback port", 400);
|
||||
}
|
||||
} else if (typeof appCallbackUrl === "string" && appCallbackUrl) {
|
||||
let callbackUrl: URL;
|
||||
try {
|
||||
callbackUrl = new URL(appCallbackUrl);
|
||||
} catch {
|
||||
throw new LoginMethodError("Invalid app callback URL", 400);
|
||||
}
|
||||
if (callbackUrl.protocol !== "termix-mobile:") {
|
||||
throw new LoginMethodError("Unsupported app callback URL", 400);
|
||||
}
|
||||
frontendOrigin = callbackUrl.toString();
|
||||
} else if (referer) {
|
||||
const refererUrl = new URL(referer);
|
||||
frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`;
|
||||
} else {
|
||||
frontendOrigin = origin;
|
||||
}
|
||||
|
||||
const codeVerifier = generatePkceCodeVerifier();
|
||||
const codeChallenge = generatePkceCodeChallenge(codeVerifier);
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
await settingsRepository.set(`oidc_state_${state}`, nonce);
|
||||
await settingsRepository.set(
|
||||
`oidc_backend_callback_${state}`,
|
||||
backendCallbackUri,
|
||||
);
|
||||
await settingsRepository.set(`oidc_frontend_origin_${state}`, frontendOrigin);
|
||||
await settingsRepository.set(
|
||||
`oidc_remember_me_${state}`,
|
||||
rememberMe === "true" ? "true" : "false",
|
||||
);
|
||||
await settingsRepository.set(`oidc_pkce_verifier_${state}`, codeVerifier);
|
||||
if (providerDbId != null) {
|
||||
await settingsRepository.set(
|
||||
`oidc_provider_${state}`,
|
||||
String(providerDbId),
|
||||
);
|
||||
}
|
||||
|
||||
const authUrl = new URL(config.authorization_url);
|
||||
authUrl.searchParams.set("client_id", config.client_id);
|
||||
authUrl.searchParams.set("redirect_uri", backendCallbackUri);
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
authUrl.searchParams.set("scope", config.scopes);
|
||||
authUrl.searchParams.set("state", state);
|
||||
authUrl.searchParams.set("nonce", nonce);
|
||||
authUrl.searchParams.set("code_challenge", codeChallenge);
|
||||
authUrl.searchParams.set("code_challenge_method", "S256");
|
||||
|
||||
return { auth_url: authUrl.toString(), state, nonce };
|
||||
}
|
||||
|
||||
type ProviderConfig = NonNullable<
|
||||
Awaited<ReturnType<typeof loadProviderConfig>>
|
||||
>["config"];
|
||||
|
||||
async function githubIdentity(
|
||||
code: string,
|
||||
state: string,
|
||||
config: ProviderConfig,
|
||||
providerDbId: number | null,
|
||||
backendCallbackUri: string,
|
||||
frontendOrigin: string,
|
||||
rememberMe: boolean,
|
||||
): Promise<VerifiedIdentity> {
|
||||
const fetchOptions = buildFetchOptions(config.ca_cert);
|
||||
const tokenResponse = await fetch(config.token_url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.client_id,
|
||||
client_secret: config.client_secret,
|
||||
code,
|
||||
redirect_uri: backendCallbackUri,
|
||||
}),
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
authLogger.error("GitHub token exchange failed", {
|
||||
operation: "github_token_exchange_failed",
|
||||
status: tokenResponse.status,
|
||||
errorResponse: errorText,
|
||||
});
|
||||
throw new LoginMethodError("Failed to exchange authorization code", 400);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as Record<string, unknown>;
|
||||
await deleteOIDCStateSettings(state);
|
||||
|
||||
const userInfoResponse = await fetch("https://api.github.com/user", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Termix",
|
||||
},
|
||||
...fetchOptions,
|
||||
});
|
||||
if (!userInfoResponse.ok) {
|
||||
throw new LoginMethodError("Failed to get GitHub user information", 400);
|
||||
}
|
||||
const userInfo = (await userInfoResponse.json()) as Record<string, unknown>;
|
||||
|
||||
const emailResponse = await fetch("https://api.github.com/user/emails", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenData.access_token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "Termix",
|
||||
},
|
||||
});
|
||||
if (emailResponse.ok) {
|
||||
const emails = (await emailResponse.json()) as Array<{
|
||||
email: string;
|
||||
primary: boolean;
|
||||
verified: boolean;
|
||||
}>;
|
||||
const primary = emails.find((e) => e.primary && e.verified);
|
||||
if (primary) userInfo.email = primary.email;
|
||||
}
|
||||
|
||||
const subject = String(userInfo.id ?? userInfo.login);
|
||||
const legacyIdentifier = `github:${providerDbId}:${subject}`;
|
||||
return {
|
||||
kind: "external",
|
||||
provider: String(providerDbId ?? LEGACY_OIDC_PROVIDER_ID),
|
||||
subject,
|
||||
email: (userInfo.email as string | undefined) ?? null,
|
||||
name: ((userInfo.name || userInfo.login) as string) || legacyIdentifier,
|
||||
allowedUsers: config.allowed_users ?? null,
|
||||
legacyIdentifier,
|
||||
ssoProviderId: providerDbId,
|
||||
returnTo: frontendOrigin,
|
||||
rememberMe,
|
||||
};
|
||||
}
|
||||
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
if (!path || !obj) return null;
|
||||
return path
|
||||
.split(".")
|
||||
.reduce<unknown>(
|
||||
(current, key) => (current as Record<string, unknown>)?.[key],
|
||||
obj,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider's callback. Throws RedirectLoginError once the return address
|
||||
* is known, and a plain LoginMethodError (answered as JSON) before that.
|
||||
*/
|
||||
export async function handleOidcCallback(
|
||||
req: Request,
|
||||
): Promise<VerifiedIdentity> {
|
||||
const { code, state } = req.query;
|
||||
if (!nonEmpty(code) || !nonEmpty(state)) {
|
||||
throw new LoginMethodError("Code and state are required", 400);
|
||||
}
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
const backendCallbackUri = await settingsRepository.get(
|
||||
`oidc_backend_callback_${state}`,
|
||||
);
|
||||
const frontendOrigin = await settingsRepository.get(
|
||||
`oidc_frontend_origin_${state}`,
|
||||
);
|
||||
const rememberMe =
|
||||
(await settingsRepository.get(`oidc_remember_me_${state}`)) === "true";
|
||||
const codeVerifier = await settingsRepository.get(
|
||||
`oidc_pkce_verifier_${state}`,
|
||||
);
|
||||
|
||||
if (!backendCallbackUri || !frontendOrigin) {
|
||||
throw new LoginMethodError(
|
||||
"Invalid state parameter - redirect URIs not found",
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const storedNonce = await settingsRepository.get(`oidc_state_${state}`);
|
||||
if (!storedNonce) {
|
||||
throw new LoginMethodError("Invalid state parameter", 400);
|
||||
}
|
||||
|
||||
const storedProviderId = await settingsRepository.get(
|
||||
`oidc_provider_${state}`,
|
||||
);
|
||||
const callbackProviderId = storedProviderId
|
||||
? parseInt(storedProviderId, 10)
|
||||
: null;
|
||||
const providerResult = await loadProviderConfig(
|
||||
callbackProviderId || undefined,
|
||||
);
|
||||
if (!providerResult) {
|
||||
throw new LoginMethodError("OIDC not configured", 500);
|
||||
}
|
||||
const { config, providerType, providerDbId } = providerResult;
|
||||
await settingsRepository.delete(`oidc_provider_${state}`);
|
||||
|
||||
// GitHub does not issue OIDC id_tokens; it has its own token exchange.
|
||||
if (providerType === "github") {
|
||||
return await githubIdentity(
|
||||
code,
|
||||
state,
|
||||
config,
|
||||
providerDbId ?? null,
|
||||
backendCallbackUri,
|
||||
frontendOrigin,
|
||||
rememberMe,
|
||||
);
|
||||
}
|
||||
|
||||
const caCert = config.ca_cert;
|
||||
const fetchOptions = buildFetchOptions(caCert);
|
||||
const tokenResponse = await fetch(config.token_url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.client_id,
|
||||
client_secret: config.client_secret,
|
||||
code,
|
||||
redirect_uri: backendCallbackUri,
|
||||
...(codeVerifier ? { code_verifier: codeVerifier } : {}),
|
||||
}),
|
||||
...fetchOptions,
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
authLogger.error("OIDC token exchange failed", {
|
||||
operation: "oidc_token_exchange_failed",
|
||||
status: tokenResponse.status,
|
||||
statusText: tokenResponse.statusText,
|
||||
backendCallbackUri,
|
||||
frontendOrigin,
|
||||
errorResponse: errorText,
|
||||
});
|
||||
throw new LoginMethodError("Failed to exchange authorization code", 400);
|
||||
}
|
||||
|
||||
const tokenData = (await tokenResponse.json()) as Record<string, unknown>;
|
||||
await deleteOIDCStateSettings(state);
|
||||
|
||||
let userInfo: Record<string, unknown> | null = null;
|
||||
const claimSources: Record<string, unknown>[] = [];
|
||||
const userInfoUrls: string[] = [];
|
||||
|
||||
const normalizedIssuerUrl = config.issuer_url.endsWith("/")
|
||||
? config.issuer_url.slice(0, -1)
|
||||
: config.issuer_url;
|
||||
const baseUrl = normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, "");
|
||||
|
||||
try {
|
||||
const discoveryResponse = await fetch(
|
||||
`${normalizedIssuerUrl}/.well-known/openid-configuration`,
|
||||
fetchOptions,
|
||||
);
|
||||
if (discoveryResponse.ok) {
|
||||
const discovery = (await discoveryResponse.json()) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (discovery.userinfo_endpoint) {
|
||||
userInfoUrls.push(discovery.userinfo_endpoint as string);
|
||||
}
|
||||
}
|
||||
} catch (discoveryError) {
|
||||
authLogger.error(`OIDC discovery failed: ${discoveryError}`);
|
||||
}
|
||||
|
||||
if (config.userinfo_url) userInfoUrls.unshift(config.userinfo_url);
|
||||
userInfoUrls.push(
|
||||
`${baseUrl}/userinfo/`,
|
||||
`${baseUrl}/userinfo`,
|
||||
`${normalizedIssuerUrl}/userinfo/`,
|
||||
`${normalizedIssuerUrl}/userinfo`,
|
||||
`${baseUrl}/oauth2/userinfo/`,
|
||||
`${baseUrl}/oauth2/userinfo`,
|
||||
`${normalizedIssuerUrl}/oauth2/userinfo/`,
|
||||
`${normalizedIssuerUrl}/oauth2/userinfo`,
|
||||
);
|
||||
|
||||
if (tokenData.id_token) {
|
||||
try {
|
||||
userInfo = await verifyOIDCToken(
|
||||
tokenData.id_token as string,
|
||||
config.issuer_url,
|
||||
config.client_id,
|
||||
caCert,
|
||||
);
|
||||
if (userInfo.nonce !== storedNonce) {
|
||||
authLogger.warn("OIDC ID token nonce mismatch", {
|
||||
operation: "oidc_nonce_mismatch",
|
||||
providerId: callbackProviderId,
|
||||
});
|
||||
throw new LoginMethodError("Invalid OIDC token nonce", 401);
|
||||
}
|
||||
claimSources.push(userInfo);
|
||||
} catch (error) {
|
||||
// A token that is not a JWS carries no claims we could trust; fall
|
||||
// through to userinfo. Signature and claim failures still reject.
|
||||
if (!(error instanceof OIDCTokenFormatError)) throw error;
|
||||
userInfo = null;
|
||||
authLogger.warn(
|
||||
"OIDC ID token cannot be verified, falling back to userinfo endpoint",
|
||||
{
|
||||
operation: "oidc_id_token_unverifiable",
|
||||
providerId: callbackProviderId,
|
||||
reason: error.message,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenData.access_token) {
|
||||
for (const userInfoUrl of userInfoUrls) {
|
||||
try {
|
||||
const response = await fetch(userInfoUrl, {
|
||||
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
||||
...fetchOptions,
|
||||
});
|
||||
if (response.ok) {
|
||||
const fetched = (await response.json()) as Record<string, unknown>;
|
||||
claimSources.push(fetched);
|
||||
userInfo = { ...userInfo, ...fetched };
|
||||
break;
|
||||
}
|
||||
authLogger.error(
|
||||
`Userinfo endpoint ${userInfoUrl} failed with status: ${response.status}`,
|
||||
);
|
||||
} catch (error) {
|
||||
authLogger.error(`Userinfo endpoint ${userInfoUrl} failed:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!userInfo) {
|
||||
authLogger.error("Failed to get user information from all sources");
|
||||
throw new LoginMethodError("Failed to get user information", 400);
|
||||
}
|
||||
|
||||
const identifier = (getNestedValue(userInfo, config.identifier_path) ||
|
||||
userInfo[config.identifier_path] ||
|
||||
userInfo.sub ||
|
||||
userInfo.email ||
|
||||
userInfo.preferred_username) as string;
|
||||
const name = (getNestedValue(userInfo, config.name_path) ||
|
||||
userInfo[config.name_path] ||
|
||||
userInfo.name ||
|
||||
userInfo.given_name ||
|
||||
identifier) as string;
|
||||
|
||||
if (!identifier) {
|
||||
throw new LoginMethodError(
|
||||
`User identifier not found at path: ${config.identifier_path}. Available fields: ${Object.keys(userInfo).join(", ")}`,
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
let isAdmin: boolean | undefined;
|
||||
if (config.admin_group) {
|
||||
const groups = extractOidcGroupsFromSources(
|
||||
claimSources,
|
||||
config.group_claim,
|
||||
);
|
||||
isAdmin = groups.includes(config.admin_group);
|
||||
}
|
||||
|
||||
let roleSync: { desired: string[]; managed: string[] } | undefined;
|
||||
try {
|
||||
const roleMap = parseOidcRoleMap(
|
||||
config.role_map ?? process.env.OIDC_ROLE_MAP,
|
||||
);
|
||||
if (roleMap.size > 0) {
|
||||
const groups = extractOidcGroupsFromSources(
|
||||
claimSources,
|
||||
config.group_claim,
|
||||
);
|
||||
const { desired, managed } = resolveOidcMappedRoles(groups, roleMap);
|
||||
roleSync = { desired: [...desired], managed: [...managed] };
|
||||
}
|
||||
} catch (roleMapError) {
|
||||
authLogger.error("Failed to read the OIDC role map", roleMapError, {
|
||||
operation: "oidc_role_map_sync_failed",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "external",
|
||||
provider: String(providerDbId ?? LEGACY_OIDC_PROVIDER_ID),
|
||||
subject: identifier,
|
||||
email: (userInfo.email as string | undefined) ?? null,
|
||||
name,
|
||||
isAdmin,
|
||||
allowedUsers: config.allowed_users ?? null,
|
||||
legacyIdentifier: identifier,
|
||||
ssoProviderId: providerDbId ?? null,
|
||||
oidcSub: typeof userInfo.sub === "string" ? userInfo.sub : null,
|
||||
oidcSid: typeof userInfo.sid === "string" ? userInfo.sid : null,
|
||||
roleSync,
|
||||
returnTo: frontendOrigin,
|
||||
rememberMe,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof LoginMethodError) throw error;
|
||||
authLogger.error("OIDC callback failed", error);
|
||||
throw new RedirectLoginError(
|
||||
"OIDC authentication failed",
|
||||
frontendOrigin,
|
||||
500,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import type { UserRecord } from "../database/repositories/user-repository.js";
|
||||
import { isOIDCUserAllowed } from "../database/routes/user-oidc-utils.js";
|
||||
import { isExternalUserAllowed } from "./allowed-users.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "./types.js";
|
||||
|
||||
type ExternalIdentity = Extract<VerifiedIdentity, { kind: "external" }>;
|
||||
@@ -64,9 +64,9 @@ function isAllowed(identity: ExternalIdentity): boolean {
|
||||
if (!identity.allowedUsers) return true;
|
||||
const email = identity.email ?? undefined;
|
||||
return (
|
||||
isOIDCUserAllowed(identity.allowedUsers, identity.subject, email) ||
|
||||
isExternalUserAllowed(identity.allowedUsers, identity.subject, email) ||
|
||||
(!!identity.legacyIdentifier &&
|
||||
isOIDCUserAllowed(
|
||||
isExternalUserAllowed(
|
||||
identity.allowedUsers,
|
||||
identity.legacyIdentifier,
|
||||
email,
|
||||
|
||||
@@ -675,17 +675,6 @@ async function initializeCompleteDatabase(): Promise<void> {
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sso_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
`);
|
||||
|
||||
// Expired open tabs are dropped when the tab list is read, against the
|
||||
@@ -1879,40 +1868,6 @@ const migrateSchema = () => {
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate legacy single oidc_config settings blob into sso_providers table
|
||||
try {
|
||||
const migrationDone = getRawSettingValue("sso_migration_v1");
|
||||
if (!migrationDone) {
|
||||
const providerCount = (
|
||||
sqlite.prepare("SELECT COUNT(*) as c FROM sso_providers").get() as {
|
||||
c: number;
|
||||
}
|
||||
).c;
|
||||
if (providerCount === 0) {
|
||||
const legacyConfig = getRawSettingValue("oidc_config");
|
||||
if (legacyConfig) {
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO sso_providers (name, type, enabled, display_order, config) VALUES (?, 'oidc', 1, 0, ?)",
|
||||
)
|
||||
.run("OIDC", legacyConfig);
|
||||
databaseLogger.info(
|
||||
"Migrated legacy oidc_config into sso_providers table",
|
||||
{
|
||||
operation: "sso_migration_v1",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
setRawSettingValue("sso_migration_v1", "true");
|
||||
}
|
||||
} catch (e) {
|
||||
databaseLogger.warn("Failed to run SSO migration v1", {
|
||||
operation: "sso_migration_v1",
|
||||
error: e,
|
||||
});
|
||||
}
|
||||
|
||||
// --- alerts begin ---
|
||||
// alert_rules, alert_rule_channels and alert_firings were only ever created
|
||||
// here, never declared in schema.ts, so they never existed on Postgres or
|
||||
|
||||
@@ -49,21 +49,6 @@ export const settings = mysqlTable("settings", {
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
export const ssoProviders = mysqlTable("sso_providers", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
type: text("type").notNull(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
displayOrder: int("display_order").notNull().default(0),
|
||||
config: text("config").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
});
|
||||
|
||||
export const sessions = mysqlTable(
|
||||
"sessions",
|
||||
{
|
||||
|
||||
@@ -50,21 +50,6 @@ export const settings = pgTable("settings", {
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
export const ssoProviders = pgTable("sso_providers", {
|
||||
id: serial("id").primaryKey(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
type: text("type").notNull(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
displayOrder: integer("display_order").notNull().default(0),
|
||||
config: text("config").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sessions = pgTable(
|
||||
"sessions",
|
||||
{
|
||||
|
||||
@@ -40,21 +40,6 @@ export const settings = sqliteTable("settings", {
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
export const ssoProviders = sqliteTable("sso_providers", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").notNull(),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
displayOrder: integer("display_order").notNull().default(0),
|
||||
config: text("config").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable(
|
||||
"sessions",
|
||||
{
|
||||
|
||||
@@ -37,7 +37,6 @@ import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-re
|
||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
|
||||
import { SsoProviderRepository } from "./sso-provider-repository.js";
|
||||
import { TermixIdentityCaRepository } from "./termix-identity-ca-repository.js";
|
||||
import { TermixIdentityRepository } from "./termix-identity-repository.js";
|
||||
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
|
||||
@@ -360,13 +359,6 @@ export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageR
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSsoProviderRepository(): SsoProviderRepository {
|
||||
return new SsoProviderRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("sso_provider_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentTermixIdentityCaRepository(): TermixIdentityCaRepository {
|
||||
return new TermixIdentityCaRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { asc, eq } from "drizzle-orm";
|
||||
import { ssoProviders, users } from "../db/schema.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
import { rowsAffected } from "./mutation-result.js";
|
||||
import { insertReturning, updateReturning } from "./returning.js";
|
||||
|
||||
export type SsoProviderRecord = typeof ssoProviders.$inferSelect;
|
||||
export type NewSsoProviderRecord = typeof ssoProviders.$inferInsert;
|
||||
export type SsoProviderUpdate = Partial<
|
||||
Pick<
|
||||
NewSsoProviderRecord,
|
||||
"name" | "type" | "enabled" | "displayOrder" | "config" | "updatedAt"
|
||||
>
|
||||
>;
|
||||
|
||||
export type PublicSsoProviderRecord = Pick<
|
||||
SsoProviderRecord,
|
||||
"id" | "name" | "type" | "displayOrder"
|
||||
>;
|
||||
|
||||
export class SsoProviderRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async listEnabledPublic(): Promise<PublicSsoProviderRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select({
|
||||
id: ssoProviders.id,
|
||||
name: ssoProviders.name,
|
||||
type: ssoProviders.type,
|
||||
displayOrder: ssoProviders.displayOrder,
|
||||
})
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.enabled, true))
|
||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||
}
|
||||
|
||||
async listEnabled(): Promise<SsoProviderRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.enabled, true))
|
||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||
}
|
||||
|
||||
async listAll(): Promise<SsoProviderRecord[]> {
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||
}
|
||||
|
||||
async findById(id: number): Promise<SsoProviderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.id, id))
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async findFirstEnabledOidcLike(): Promise<SsoProviderRecord | null> {
|
||||
const rows = await this.context.drizzle
|
||||
.select()
|
||||
.from(ssoProviders)
|
||||
.where(eq(ssoProviders.enabled, true))
|
||||
.orderBy(asc(ssoProviders.displayOrder), asc(ssoProviders.id));
|
||||
|
||||
return (
|
||||
rows.find(
|
||||
(row) =>
|
||||
row.type === "oidc" || row.type === "github" || row.type === "google",
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async create(provider: NewSsoProviderRecord): Promise<SsoProviderRecord> {
|
||||
const rows = await insertReturning(this.context, ssoProviders, provider);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
async update(
|
||||
id: number,
|
||||
update: SsoProviderUpdate,
|
||||
): Promise<SsoProviderRecord | null> {
|
||||
const rows = await updateReturning(
|
||||
this.context,
|
||||
ssoProviders,
|
||||
update,
|
||||
eq(ssoProviders.id, id),
|
||||
);
|
||||
|
||||
await this.afterWrite();
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async delete(id: number): Promise<boolean> {
|
||||
const result = await this.context.drizzle
|
||||
.delete(ssoProviders)
|
||||
.where(eq(ssoProviders.id, id));
|
||||
|
||||
if (rowsAffected(result) > 0) {
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
return rowsAffected(result) > 0;
|
||||
}
|
||||
|
||||
async countUsersByProviderId(providerId: number): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.ssoProviderId, providerId));
|
||||
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,14 @@ export class UserAuthRepository {
|
||||
.where(eq(userExternalIdentities.userId, userId));
|
||||
}
|
||||
|
||||
async countUsersForProvider(providerId: string): Promise<number> {
|
||||
const rows = await this.context.drizzle
|
||||
.select({ userId: userExternalIdentities.userId })
|
||||
.from(userExternalIdentities)
|
||||
.where(eq(userExternalIdentities.providerId, providerId));
|
||||
return new Set(rows.map((row) => row.userId)).size;
|
||||
}
|
||||
|
||||
async listAllIdentities(): Promise<UserExternalIdentityRecord[]> {
|
||||
return this.context.drizzle.select().from(userExternalIdentities);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* The 2.8 login URLs that something outside Termix still depends on:
|
||||
* identity providers configured with the old callback and back-channel
|
||||
* logout URLs, and clients built against 2.8 such as Termix-Mobile. Each one
|
||||
* forwards to the login method or plugin route that replaced it, so none of
|
||||
* them can sign anyone in on its own.
|
||||
*/
|
||||
|
||||
import type { Request, Router } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { getRequestBasePath } from "../../utils/request-origin.js";
|
||||
import {
|
||||
listLegacySsoProviders,
|
||||
startRedirectLogin,
|
||||
verifyFormLogin,
|
||||
} from "./auth-routes.js";
|
||||
|
||||
/**
|
||||
* A path on this install, keeping the query string. Relative, so the browser
|
||||
* stays on the scheme and host it used, whatever a proxy told us.
|
||||
*/
|
||||
function pluginUrl(req: Request, path: string): string {
|
||||
const query = req.originalUrl.indexOf("?");
|
||||
return `${getRequestBasePath(req)}${path}${
|
||||
query >= 0 ? req.originalUrl.slice(query) : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
export function registerAuthCompatRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-config:
|
||||
* get:
|
||||
* summary: Get the default SSO provider's public configuration (2.8 route)
|
||||
* description: Kept for 2.8 clients. Redirects to the sso plugin's /plugin-api/sso/config.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 307:
|
||||
* description: Redirect to the sso plugin.
|
||||
*/
|
||||
router.get("/oidc-config", (req, res) => {
|
||||
res.redirect(307, pluginUrl(req, "/plugin-api/sso/config"));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc/authorize:
|
||||
* get:
|
||||
* summary: Start an SSO login (2.8 route)
|
||||
* description: Kept for 2.8 clients such as Termix-Mobile. Same as /users/auth/oidc/start, with the provider in providerId.
|
||||
* tags:
|
||||
* - Auth
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: providerId
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: rememberMe
|
||||
* schema:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The authorization URL in auth_url.
|
||||
* 404:
|
||||
* description: SSO is not available.
|
||||
*/
|
||||
router.get("/oidc/authorize", (req, res) =>
|
||||
startRedirectLogin(
|
||||
req,
|
||||
res,
|
||||
"oidc",
|
||||
typeof req.query.providerId === "string" && req.query.providerId
|
||||
? req.query.providerId
|
||||
: null,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc/callback:
|
||||
* get:
|
||||
* summary: SSO callback (2.8 URL)
|
||||
* description: The redirect URI identity providers were set up with before 2.9. Permanently redirects to /plugin-api/sso/callback with the same query, so existing provider configurations keep working.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 308:
|
||||
* description: Redirect to the sso plugin's callback.
|
||||
* post:
|
||||
* summary: SSO callback, form post (2.8 URL)
|
||||
* description: Same as the GET form.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 308:
|
||||
* description: Redirect to the sso plugin's callback.
|
||||
*/
|
||||
router.all("/oidc/callback", (req, res) => {
|
||||
res.redirect(308, pluginUrl(req, "/plugin-api/sso/callback"));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc/backchannel-logout:
|
||||
* post:
|
||||
* summary: OIDC back-channel logout (2.8 URL)
|
||||
* description: Permanently redirects to /plugin-api/sso/backchannel-logout, so identity providers set up before 2.9 keep working.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 308:
|
||||
* description: Redirect to the sso plugin.
|
||||
*/
|
||||
router.post("/oidc/backchannel-logout", (req, res) => {
|
||||
res.redirect(308, pluginUrl(req, "/plugin-api/sso/backchannel-logout"));
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers:
|
||||
* get:
|
||||
* summary: List sign-in providers (2.8 route)
|
||||
* description: Kept for 2.8 clients. Every enabled instance of every external login method, in the 2.8 shape. /users/auth/methods is the current route.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Providers.
|
||||
*/
|
||||
router.get("/sso-providers", async (_req, res) => {
|
||||
try {
|
||||
res.json(await listLegacySsoProviders());
|
||||
} catch (error) {
|
||||
authLogger.error("Failed to list SSO providers", error);
|
||||
res.status(500).json({ error: "Failed to list SSO providers" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/ldap/login:
|
||||
* post:
|
||||
* summary: LDAP login (2.8 route)
|
||||
* description: Kept for 2.8 clients. Same as /users/auth/ldap/verify, with the provider in providerId.
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful, or a second factor is required.
|
||||
* 401:
|
||||
* description: Invalid credentials.
|
||||
* 404:
|
||||
* description: LDAP is not available.
|
||||
*/
|
||||
router.post("/ldap/login", (req, res) =>
|
||||
verifyFormLogin(
|
||||
req,
|
||||
res,
|
||||
"ldap",
|
||||
req.body?.providerId != null ? String(req.body.providerId) : null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,12 @@ import {
|
||||
verifySecondFactorAndRespond,
|
||||
evaluateSecondFactors,
|
||||
} from "../../auth/login-pipeline.js";
|
||||
import { getLoginMethod, listLoginMethods } from "../../auth/registry.js";
|
||||
import {
|
||||
getLoginMethod,
|
||||
listLoginMethods,
|
||||
type LoginMethod,
|
||||
} from "../../auth/registry.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
|
||||
import { LoginMethodError, type VerifiedIdentity } from "../../auth/types.js";
|
||||
|
||||
export interface PublicLoginMethod {
|
||||
@@ -30,6 +35,59 @@ export interface PublicLoginMethod {
|
||||
instances: Array<{ id: string; label: string }>;
|
||||
}
|
||||
|
||||
async function enabledInstances(method: LoginMethod) {
|
||||
return (await method.describe!()).filter((instance) => instance.enabled);
|
||||
}
|
||||
|
||||
/** External methods with at least one enabled instance. */
|
||||
export async function listExternalLoginMethods(): Promise<LoginMethod[]> {
|
||||
ensureCoreLoginProviders();
|
||||
const found: LoginMethod[] = [];
|
||||
for (const method of listLoginMethods()) {
|
||||
if (!method.external) continue;
|
||||
if (!method.describe) {
|
||||
found.push(method);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if ((await enabledInstances(method)).length > 0) found.push(method);
|
||||
} catch {
|
||||
// A method that cannot describe itself signs nobody in.
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The 2.8 `GET /users/sso-providers` shape, built from external methods. */
|
||||
export async function listLegacySsoProviders(): Promise<
|
||||
Array<{
|
||||
id: number | string;
|
||||
name: string;
|
||||
type: string;
|
||||
displayOrder: number;
|
||||
}>
|
||||
> {
|
||||
const providers: Array<{
|
||||
id: number | string;
|
||||
name: string;
|
||||
type: string;
|
||||
displayOrder: number;
|
||||
}> = [];
|
||||
for (const method of await listExternalLoginMethods()) {
|
||||
if (!method.describe) continue;
|
||||
for (const instance of await enabledInstances(method)) {
|
||||
const numeric = Number(instance.id);
|
||||
providers.push({
|
||||
id: Number.isInteger(numeric) ? numeric : instance.id,
|
||||
name: instance.label,
|
||||
type: instance.type ?? method.id,
|
||||
displayOrder: providers.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
/** What the login screen may know: ids, labels and enabled instances. */
|
||||
export async function listPublicLoginMethods(): Promise<PublicLoginMethod[]> {
|
||||
ensureCoreLoginProviders();
|
||||
@@ -68,16 +126,111 @@ function instanceParam(req: Request): string | null {
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
function methodOr404(req: Request, res: Response) {
|
||||
/**
|
||||
* Trusted proxy login and external methods don't mix: the proxy already
|
||||
* decided who the user is.
|
||||
*/
|
||||
function refusedByTrustedProxy(method: LoginMethod, res: Response): boolean {
|
||||
if (!method.external || !isTrustedProxyAuthEnabled()) return false;
|
||||
res.status(409).json({
|
||||
error:
|
||||
"External login is disabled while trusted proxy authentication is enabled",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function methodOr404(req: Request, res: Response, id?: string) {
|
||||
ensureCoreLoginProviders();
|
||||
const method = getLoginMethod(String(req.params.methodId));
|
||||
const method = getLoginMethod(id ?? String(req.params.methodId));
|
||||
if (!method) {
|
||||
res.status(404).json({ error: "Unknown login method" });
|
||||
return null;
|
||||
}
|
||||
if (refusedByTrustedProxy(method, res)) return null;
|
||||
return method;
|
||||
}
|
||||
|
||||
/**
|
||||
* A redirect method's callback, for core's route and for a plugin's own
|
||||
* public route through ctx.auth.completeRedirectLogin.
|
||||
*/
|
||||
export async function handleRedirectCallback(
|
||||
method: LoginMethod,
|
||||
req: Request,
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
if (refusedByTrustedProxy(method, res)) return;
|
||||
if (!method.callback) {
|
||||
res.status(400).json({ error: "Not a redirect login method" });
|
||||
return;
|
||||
}
|
||||
let identity: VerifiedIdentity;
|
||||
try {
|
||||
identity = await method.callback(req as never);
|
||||
} catch (error) {
|
||||
const returnTo = (error as { returnTo?: string }).returnTo;
|
||||
if (returnTo) return redirectWithLoginError(res, returnTo, error);
|
||||
sendLoginError(res, error);
|
||||
return;
|
||||
}
|
||||
await respondWithRedirectLogin(
|
||||
req,
|
||||
res,
|
||||
identity,
|
||||
{ methodId: method.id, rememberMe: !!identity.rememberMe },
|
||||
isOidcTokenCallback,
|
||||
);
|
||||
}
|
||||
|
||||
/** Starts a redirect method, answering with its URL. */
|
||||
export async function startRedirectLogin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
methodId: string,
|
||||
instanceId: string | null,
|
||||
): Promise<void> {
|
||||
const method = methodOr404(req, res, methodId);
|
||||
if (!method) return;
|
||||
if (method.kind !== "redirect" || !method.start) {
|
||||
res.status(400).json({ error: "Not a redirect login method" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await method.start(req as never, instanceId);
|
||||
// auth_url is the name 2.8 clients read.
|
||||
res.json({ ...result, auth_url: result.redirectUrl });
|
||||
} catch (error) {
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a form method and answers with a session or a second-factor step. */
|
||||
export async function verifyFormLogin(
|
||||
req: Request,
|
||||
res: Response,
|
||||
methodId: string,
|
||||
instanceId: string | null,
|
||||
): Promise<void> {
|
||||
const method = methodOr404(req, res, methodId);
|
||||
if (!method) return;
|
||||
if (method.kind !== "form" || !method.verify) {
|
||||
res.status(400).json({ error: "Not a form login method" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const identity = await method.verify(req as never, instanceId);
|
||||
await respondWithLogin(req, res, identity, {
|
||||
methodId: method.id,
|
||||
rememberMe: !!identity.rememberMe || !!req.body?.rememberMe,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof LoginMethodError)) {
|
||||
authLogger.error("Form login failed", error, { methodId: method.id });
|
||||
}
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAuthRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
@@ -243,40 +396,19 @@ export function registerAuthRoutes(router: Router): void {
|
||||
* 404:
|
||||
* description: Unknown login method.
|
||||
*/
|
||||
router.get("/auth/:methodId/start", async (req, res) => {
|
||||
const method = methodOr404(req, res);
|
||||
if (!method) return;
|
||||
if (method.kind !== "redirect" || !method.start) {
|
||||
return res.status(400).json({ error: "Not a redirect login method" });
|
||||
}
|
||||
try {
|
||||
res.json(await method.start(req as never, instanceParam(req)));
|
||||
} catch (error) {
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
router.get("/auth/:methodId/start", (req, res) =>
|
||||
startRedirectLogin(
|
||||
req,
|
||||
res,
|
||||
String(req.params.methodId),
|
||||
instanceParam(req),
|
||||
),
|
||||
);
|
||||
|
||||
const callback = async (req: Request, res: Response) => {
|
||||
const method = methodOr404(req, res);
|
||||
if (!method) return;
|
||||
if (!method.callback) {
|
||||
return res.status(400).json({ error: "Not a redirect login method" });
|
||||
}
|
||||
let identity: VerifiedIdentity;
|
||||
try {
|
||||
identity = await method.callback(req as never);
|
||||
} catch (error) {
|
||||
const returnTo = (error as { returnTo?: string }).returnTo;
|
||||
if (returnTo) return redirectWithLoginError(res, returnTo, error);
|
||||
return sendLoginError(res, error);
|
||||
}
|
||||
await respondWithRedirectLogin(
|
||||
req,
|
||||
res,
|
||||
identity,
|
||||
{ methodId: method.id, rememberMe: !!identity.rememberMe },
|
||||
isOidcTokenCallback,
|
||||
);
|
||||
await handleRedirectCallback(method, req, res);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -336,23 +468,7 @@ export function registerAuthRoutes(router: Router): void {
|
||||
* 404:
|
||||
* description: Unknown login method.
|
||||
*/
|
||||
router.post("/auth/:methodId/verify", async (req, res) => {
|
||||
const method = methodOr404(req, res);
|
||||
if (!method) return;
|
||||
if (method.kind !== "form" || !method.verify) {
|
||||
return res.status(400).json({ error: "Not a form login method" });
|
||||
}
|
||||
try {
|
||||
const identity = await method.verify(req as never, instanceParam(req));
|
||||
await respondWithLogin(req, res, identity, {
|
||||
methodId: method.id,
|
||||
rememberMe: !!identity.rememberMe || !!req.body?.rememberMe,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof LoginMethodError)) {
|
||||
authLogger.error("Form login failed", error, { methodId: method.id });
|
||||
}
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
router.post("/auth/:methodId/verify", (req, res) =>
|
||||
verifyFormLogin(req, res, String(req.params.methodId), instanceParam(req)),
|
||||
);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { Router } from "express";
|
||||
import { verifyLdapLogin } from "../../auth/legacy/ldap-login.js";
|
||||
import { respondWithLogin, sendLoginError } from "../../auth/login-pipeline.js";
|
||||
|
||||
export function registerLDAPAuthRoutes(router: Router): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /users/ldap/login:
|
||||
* post:
|
||||
* summary: LDAP login
|
||||
* description: Authenticates a user against an LDAP server, then runs the same second factors and session issuing as every other login.
|
||||
* tags:
|
||||
* - SSO
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* providerId:
|
||||
* type: integer
|
||||
* username:
|
||||
* type: string
|
||||
* password:
|
||||
* type: string
|
||||
* rememberMe:
|
||||
* type: boolean
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Login successful, or a second factor is required.
|
||||
* 400:
|
||||
* description: Missing fields.
|
||||
* 401:
|
||||
* description: Invalid credentials.
|
||||
* 403:
|
||||
* description: User not allowed or registration disabled.
|
||||
* 404:
|
||||
* description: Provider not found.
|
||||
* 429:
|
||||
* description: Too many login attempts.
|
||||
*/
|
||||
router.post("/ldap/login", async (req, res) => {
|
||||
try {
|
||||
const identity = await verifyLdapLogin({ body: req.body, ip: req.ip });
|
||||
await respondWithLogin(req, res, identity, {
|
||||
methodId: "ldap",
|
||||
rememberMe: !!req.body?.rememberMe,
|
||||
});
|
||||
} catch (error) {
|
||||
sendLoginError(res, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
import type {
|
||||
AuthenticatedRequest,
|
||||
OIDCProviderConfig,
|
||||
} from "../../../types/index.js";
|
||||
import type { Router } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import type { SSOProviderType } from "../../../types/index.js";
|
||||
import { createCurrentSsoProviderRepository } from "../repositories/factory.js";
|
||||
import {
|
||||
getOIDCConfigFromEnv,
|
||||
isOIDCEnvOverrideEnabled,
|
||||
} from "./user-oidc-utils.js";
|
||||
import {
|
||||
decryptSsoConfigSecrets,
|
||||
encryptSsoConfigSecrets,
|
||||
} from "../../utils/system-secret-crypto.js";
|
||||
import { isTrustedProxyAuthEnabled } from "../../utils/trusted-proxy-auth.js";
|
||||
|
||||
function isOidcLike(type: SSOProviderType): boolean {
|
||||
return type === "oidc" || type === "github" || type === "google";
|
||||
}
|
||||
|
||||
export function isValidOidcIssuer(value: unknown): boolean {
|
||||
if (typeof value !== "string") return false;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
["http:", "https:"].includes(url.protocol) &&
|
||||
!/\/userinfo\/?$/i.test(url.pathname)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
/**
|
||||
* SSO secrets belong to the installation, not to a user: `sso_providers` has no
|
||||
* userId and the values must be readable during login, before anyone is
|
||||
* authenticated. They are encrypted with the system key rather than a user DEK.
|
||||
* Values written by the previous base64 scheme still decode, and are upgraded
|
||||
* the next time the provider is saved.
|
||||
*/
|
||||
async function decryptProviderConfig(
|
||||
configJson: string,
|
||||
_userId: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
let config: Record<string, unknown>;
|
||||
try {
|
||||
config = JSON.parse(configJson);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
return decryptSsoConfigSecrets(config);
|
||||
}
|
||||
|
||||
async function encryptProviderConfig(
|
||||
config: Record<string, unknown>,
|
||||
_userId: string,
|
||||
_providerId: string,
|
||||
): Promise<string> {
|
||||
return JSON.stringify(await encryptSsoConfigSecrets(config));
|
||||
}
|
||||
|
||||
function applyProviderDefaults(
|
||||
type: SSOProviderType,
|
||||
config: Partial<OIDCProviderConfig>,
|
||||
): Partial<OIDCProviderConfig> {
|
||||
if (type === "github") {
|
||||
return {
|
||||
authorization_url: "https://github.com/login/oauth/authorize",
|
||||
token_url: "https://github.com/login/oauth/access_token",
|
||||
issuer_url: "https://github.com",
|
||||
identifier_path: "id",
|
||||
name_path: "name",
|
||||
scopes: "read:user user:email",
|
||||
userinfo_url: "https://api.github.com/user",
|
||||
...config,
|
||||
};
|
||||
}
|
||||
if (type === "google") {
|
||||
return {
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
token_url: "https://oauth2.googleapis.com/token",
|
||||
issuer_url: "https://accounts.google.com",
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
scopes: "openid email profile",
|
||||
...config,
|
||||
};
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/** Enabled providers as the login screen sees them: no secrets. */
|
||||
export async function listPublicSsoProviders(): Promise<
|
||||
Array<{ id: number; name: string; type: string; displayOrder: number }>
|
||||
> {
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (envConfig && isOIDCEnvOverrideEnabled()) {
|
||||
return [{ id: 0, name: "SSO", type: "oidc", displayOrder: 0 }];
|
||||
}
|
||||
|
||||
const providers =
|
||||
await createCurrentSsoProviderRepository().listEnabledPublic();
|
||||
|
||||
// With no provider rows, synthesize one from env vars so SSO login stays
|
||||
// available when it is configured purely through the environment.
|
||||
if (providers.length === 0 && envConfig) {
|
||||
providers.push({ id: 0, name: "SSO", type: "oidc", displayOrder: 0 });
|
||||
}
|
||||
return providers;
|
||||
}
|
||||
|
||||
export function registerSSOProviderRoutes(router: Router): void {
|
||||
const requireAdmin = authManager.createAdminMiddleware();
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers:
|
||||
* get:
|
||||
* summary: List enabled SSO providers (public)
|
||||
* description: Returns public info for all enabled SSO providers for the login page.
|
||||
* tags:
|
||||
* - SSO
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of public SSO provider objects.
|
||||
*/
|
||||
router.get("/sso-providers", async (_req, res) => {
|
||||
try {
|
||||
res.json(await listPublicSsoProviders());
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to list SSO providers", err);
|
||||
res.status(500).json({ error: "Failed to list SSO providers" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers/admin:
|
||||
* get:
|
||||
* summary: List all SSO providers (admin)
|
||||
* description: Returns full SSO provider list with decrypted configs for the admin panel.
|
||||
* tags:
|
||||
* - SSO
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of full SSO provider objects with decrypted config.
|
||||
*/
|
||||
router.get("/sso-providers/admin", requireAdmin, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const rows = await createCurrentSsoProviderRepository().listAll();
|
||||
|
||||
const result = await Promise.all(
|
||||
rows.map(async (row) => ({
|
||||
...row,
|
||||
config: await decryptProviderConfig(row.config, userId),
|
||||
})),
|
||||
);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to list SSO providers (admin)", err);
|
||||
res.status(500).json({ error: "Failed to list SSO providers" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers:
|
||||
* post:
|
||||
* summary: Create SSO provider
|
||||
* description: Creates a new SSO provider configuration.
|
||||
* tags:
|
||||
* - SSO
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Provider created.
|
||||
* 400:
|
||||
* description: Validation error.
|
||||
*/
|
||||
router.post("/sso-providers", requireAdmin, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
type,
|
||||
enabled = true,
|
||||
displayOrder = 0,
|
||||
config: rawConfig = {},
|
||||
} = req.body as {
|
||||
name: string;
|
||||
type: SSOProviderType;
|
||||
enabled?: boolean;
|
||||
displayOrder?: number;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (!name || typeof name !== "string" || !name.trim()) {
|
||||
return res.status(400).json({ error: "Provider name is required" });
|
||||
}
|
||||
const validTypes: SSOProviderType[] = [
|
||||
"oidc",
|
||||
"ldap",
|
||||
"github",
|
||||
"google",
|
||||
];
|
||||
if (!validTypes.includes(type)) {
|
||||
return res.status(400).json({ error: "Invalid provider type" });
|
||||
}
|
||||
if (isTrustedProxyAuthEnabled() && enabled && isOidcLike(type)) {
|
||||
return res.status(409).json({
|
||||
error:
|
||||
"OIDC providers cannot be enabled with trusted proxy authentication",
|
||||
});
|
||||
}
|
||||
|
||||
const configWithDefaults =
|
||||
type === "github" || type === "google"
|
||||
? applyProviderDefaults(
|
||||
type,
|
||||
rawConfig as Partial<OIDCProviderConfig>,
|
||||
)
|
||||
: rawConfig;
|
||||
|
||||
if (type === "oidc" || type === "github" || type === "google") {
|
||||
const c = configWithDefaults as Partial<OIDCProviderConfig>;
|
||||
const missing = [
|
||||
"client_id",
|
||||
"client_secret",
|
||||
"issuer_url",
|
||||
"authorization_url",
|
||||
"token_url",
|
||||
].filter((f) => !c[f as keyof OIDCProviderConfig]);
|
||||
if (missing.length > 0 && type === "oidc") {
|
||||
return res.status(400).json({
|
||||
error: `Missing required OIDC fields: ${missing.join(", ")}`,
|
||||
});
|
||||
}
|
||||
if (c.issuer_url && !isValidOidcIssuer(c.issuer_url)) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint",
|
||||
});
|
||||
}
|
||||
if (
|
||||
(type === "github" || type === "google") &&
|
||||
(!c.client_id || !c.client_secret)
|
||||
) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Client ID and Client Secret are required" });
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "ldap") {
|
||||
const c = configWithDefaults as Record<string, unknown>;
|
||||
const missing = [
|
||||
"host",
|
||||
"port",
|
||||
"bindDN",
|
||||
"bindPassword",
|
||||
"userSearchBase",
|
||||
"userSearchFilter",
|
||||
"usernameAttribute",
|
||||
].filter((f) => !c[f]);
|
||||
if (missing.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Missing required LDAP fields: ${missing.join(", ")}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tempId = `new-${Date.now()}`;
|
||||
const encryptedConfig = await encryptProviderConfig(
|
||||
configWithDefaults as Record<string, unknown>,
|
||||
userId,
|
||||
tempId,
|
||||
);
|
||||
|
||||
const inserted = await createCurrentSsoProviderRepository().create({
|
||||
name: name.trim(),
|
||||
type,
|
||||
enabled,
|
||||
displayOrder,
|
||||
config: encryptedConfig,
|
||||
});
|
||||
|
||||
authLogger.info("SSO provider created", {
|
||||
operation: "sso_provider_create",
|
||||
userId,
|
||||
type,
|
||||
providerId: inserted.id,
|
||||
});
|
||||
res.status(201).json({
|
||||
...inserted,
|
||||
config: await decryptProviderConfig(inserted.config, userId),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to create SSO provider", err);
|
||||
res.status(500).json({ error: "Failed to create SSO provider" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers/{id}:
|
||||
* put:
|
||||
* summary: Update SSO provider
|
||||
* description: Updates an existing SSO provider configuration.
|
||||
* tags:
|
||||
* - SSO
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Provider updated.
|
||||
* 404:
|
||||
* description: Provider not found.
|
||||
*/
|
||||
router.put("/sso-providers/:id", requireAdmin, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const providerId = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(providerId)) {
|
||||
return res.status(400).json({ error: "Invalid provider ID" });
|
||||
}
|
||||
try {
|
||||
const providerRepository = createCurrentSsoProviderRepository();
|
||||
const existing = await providerRepository.findById(providerId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "SSO provider not found" });
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
type,
|
||||
enabled,
|
||||
displayOrder,
|
||||
config: rawConfig,
|
||||
} = req.body as {
|
||||
name?: string;
|
||||
type?: SSOProviderType;
|
||||
enabled?: boolean;
|
||||
displayOrder?: number;
|
||||
config?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const effectiveType = type ?? (existing.type as SSOProviderType);
|
||||
const effectiveEnabled = enabled ?? existing.enabled;
|
||||
if (
|
||||
isTrustedProxyAuthEnabled() &&
|
||||
effectiveEnabled &&
|
||||
isOidcLike(effectiveType)
|
||||
) {
|
||||
return res.status(409).json({
|
||||
error:
|
||||
"OIDC providers cannot be enabled with trusted proxy authentication",
|
||||
});
|
||||
}
|
||||
|
||||
let encryptedConfig = existing.config;
|
||||
if (rawConfig !== undefined) {
|
||||
const existingDecrypted = await decryptProviderConfig(
|
||||
existing.config,
|
||||
userId,
|
||||
);
|
||||
const mergedConfig = {
|
||||
...JSON.parse(
|
||||
existingDecrypted ? JSON.stringify(existingDecrypted) : "{}",
|
||||
),
|
||||
...rawConfig,
|
||||
};
|
||||
if (
|
||||
isOidcLike(effectiveType) &&
|
||||
mergedConfig.issuer_url &&
|
||||
!isValidOidcIssuer(mergedConfig.issuer_url)
|
||||
) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Issuer URL must be an HTTP(S) issuer and not a userinfo endpoint",
|
||||
});
|
||||
}
|
||||
encryptedConfig = await encryptProviderConfig(
|
||||
mergedConfig,
|
||||
userId,
|
||||
String(providerId),
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await providerRepository.update(providerId, {
|
||||
...(name !== undefined ? { name: name.trim() } : {}),
|
||||
...(type !== undefined ? { type } : {}),
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
...(displayOrder !== undefined ? { displayOrder } : {}),
|
||||
config: encryptedConfig,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: "SSO provider not found" });
|
||||
}
|
||||
|
||||
authLogger.info("SSO provider updated", {
|
||||
operation: "sso_provider_update",
|
||||
userId,
|
||||
providerId,
|
||||
});
|
||||
res.json({
|
||||
...updated,
|
||||
config: await decryptProviderConfig(updated.config, userId),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update SSO provider", err);
|
||||
res.status(500).json({ error: "Failed to update SSO provider" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/sso-providers/{id}:
|
||||
* delete:
|
||||
* summary: Delete SSO provider
|
||||
* description: Deletes an SSO provider. Blocked if users are associated.
|
||||
* tags:
|
||||
* - SSO
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Provider deleted.
|
||||
* 409:
|
||||
* description: Users are associated with this provider.
|
||||
* 404:
|
||||
* description: Provider not found.
|
||||
*/
|
||||
router.delete("/sso-providers/:id", requireAdmin, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const providerId = parseInt(req.params.id as string, 10);
|
||||
if (isNaN(providerId)) {
|
||||
return res.status(400).json({ error: "Invalid provider ID" });
|
||||
}
|
||||
try {
|
||||
const providerRepository = createCurrentSsoProviderRepository();
|
||||
const existing = await providerRepository.findById(providerId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "SSO provider not found" });
|
||||
}
|
||||
|
||||
const associatedUserCount =
|
||||
await providerRepository.countUsersByProviderId(providerId);
|
||||
if (associatedUserCount > 0) {
|
||||
return res.status(409).json({
|
||||
error: `Cannot delete provider: ${associatedUserCount} user(s) are associated with it`,
|
||||
});
|
||||
}
|
||||
|
||||
await providerRepository.delete(providerId);
|
||||
authLogger.info("SSO provider deleted", {
|
||||
operation: "sso_provider_delete",
|
||||
userId,
|
||||
providerId,
|
||||
});
|
||||
res.json({ message: "SSO provider deleted" });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to delete SSO provider", err);
|
||||
res.status(500).json({ error: "Failed to delete SSO provider" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { decryptProviderConfig, encryptProviderConfig };
|
||||
@@ -1,648 +0,0 @@
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import type { SSOProviderType } from "../../../types/index.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { decryptSsoConfigSecrets } from "../../utils/system-secret-crypto.js";
|
||||
import { Agent } from "undici";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentSsoProviderRepository,
|
||||
} from "../repositories/factory.js";
|
||||
|
||||
const BACKCHANNEL_LOGOUT_EVENT =
|
||||
"http://schemas.openid.net/event/backchannel-logout";
|
||||
|
||||
/**
|
||||
* Raised when a token cannot be verified because it is not a compact JWS,
|
||||
* as opposed to a signature or claim check that actually failed.
|
||||
*/
|
||||
export class OIDCTokenFormatError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "OIDCTokenFormatError";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIssuer(url: string): string {
|
||||
return url
|
||||
.trim()
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/\/\.well-known\/openid-configuration$/, "");
|
||||
}
|
||||
|
||||
export type OIDCConfig = {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
userinfo_url: string;
|
||||
identifier_path: string;
|
||||
name_path: string;
|
||||
scopes: string;
|
||||
allowed_users: string;
|
||||
admin_group: string;
|
||||
group_claim?: string;
|
||||
role_map?: string;
|
||||
ca_cert?: string;
|
||||
};
|
||||
|
||||
export function buildFetchOptions(caCert?: string): Record<string, unknown> {
|
||||
if (!caCert || !caCert.trim()) return {};
|
||||
return { dispatcher: new Agent({ connect: { ca: caCert } }) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders why a fetch failed in a form an administrator can act on.
|
||||
*
|
||||
* undici reports every transport failure as the same "fetch failed" message
|
||||
* and puts the reason that actually matters -- ENOTFOUND, ECONNREFUSED,
|
||||
* UNABLE_TO_VERIFY_LEAF_SIGNATURE, a timeout -- on the cause. Reporting only
|
||||
* the outer message says nothing at all.
|
||||
*/
|
||||
export function describeFetchFailure(error: unknown): string {
|
||||
if (!(error instanceof Error)) return String(error);
|
||||
const cause = (error as { cause?: unknown }).cause;
|
||||
if (cause instanceof Error) {
|
||||
const code = (cause as { code?: unknown }).code;
|
||||
return code
|
||||
? `${error.message}: ${cause.message} (${code})`
|
||||
: `${error.message}: ${cause.message}`;
|
||||
}
|
||||
return cause ? `${error.message}: ${String(cause)}` : error.message;
|
||||
}
|
||||
|
||||
export function getOIDCConfigFromEnv(): OIDCConfig | null {
|
||||
const client_id = process.env.OIDC_CLIENT_ID;
|
||||
const client_secret = process.env.OIDC_CLIENT_SECRET;
|
||||
const issuer_url = process.env.OIDC_ISSUER_URL;
|
||||
const authorization_url = process.env.OIDC_AUTHORIZATION_URL;
|
||||
const token_url = process.env.OIDC_TOKEN_URL;
|
||||
|
||||
if (
|
||||
!client_id ||
|
||||
!client_secret ||
|
||||
!issuer_url ||
|
||||
!authorization_url ||
|
||||
!token_url
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
client_id,
|
||||
client_secret,
|
||||
issuer_url,
|
||||
authorization_url,
|
||||
token_url,
|
||||
userinfo_url: process.env.OIDC_USERINFO_URL || "",
|
||||
identifier_path: process.env.OIDC_IDENTIFIER_PATH || "sub",
|
||||
name_path: process.env.OIDC_NAME_PATH || "name",
|
||||
scopes: process.env.OIDC_SCOPES || "openid email profile",
|
||||
allowed_users: process.env.OIDC_ALLOWED_USERS || "",
|
||||
admin_group: process.env.OIDC_ADMIN_GROUP || "",
|
||||
group_claim: process.env.OIDC_GROUP_CLAIM || "",
|
||||
role_map: process.env.OIDC_ROLE_MAP || "",
|
||||
};
|
||||
}
|
||||
|
||||
export function isOIDCEnvOverrideEnabled(): boolean {
|
||||
return process.env.OIDC_ENV_OVERRIDE?.toLowerCase() === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a group name for comparison. Providers are inconsistent about
|
||||
* whether they emit bare names (`devops-interns`) or full paths
|
||||
* (`/devops-interns`, Keycloak's "Full group path" option), so leading slashes
|
||||
* are stripped and case is ignored.
|
||||
*/
|
||||
function normalizeGroupName(group: string): string {
|
||||
return group.trim().replace(/^\/+/, "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `OIDC_ROLE_MAP` into a group -> role-name lookup.
|
||||
*
|
||||
* Format is a comma- or newline-separated list of `group:role` pairs, e.g.
|
||||
* `devops-interns:devops-intern,devops-seniors:devops-senior`. Group keys are
|
||||
* normalized via {@link normalizeGroupName}; role names are passed through
|
||||
* verbatim because they must match `roles.name` exactly.
|
||||
*
|
||||
* Malformed entries are skipped rather than throwing — a typo in one pair must
|
||||
* not lock every user out of login.
|
||||
*/
|
||||
export function parseOidcRoleMap(raw?: string | null): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
if (!raw || !raw.trim()) return map;
|
||||
|
||||
for (const entry of raw.split(/[\n,]/)) {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
// rsplit on the last ":" so group names containing a colon still work.
|
||||
const separator = trimmed.lastIndexOf(":");
|
||||
if (separator <= 0 || separator === trimmed.length - 1) continue;
|
||||
|
||||
const group = normalizeGroupName(trimmed.slice(0, separator));
|
||||
const roleName = trimmed.slice(separator + 1).trim();
|
||||
if (!group || !roleName) continue;
|
||||
|
||||
map.set(group, roleName);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves which mapped roles a user should hold, given their provider groups.
|
||||
*
|
||||
* Returns both the `desired` roles (mapped groups the user is actually in) and
|
||||
* the full set of `managed` roles (every role named in the map). Callers must
|
||||
* only ever add/remove roles within `managed` — roles assigned by hand in
|
||||
* Termix, and the `admin`/`user` roles maintained by the admin-group sync, are
|
||||
* deliberately left alone.
|
||||
*/
|
||||
export function resolveOidcMappedRoles(
|
||||
groups: string[],
|
||||
roleMap: Map<string, string>,
|
||||
): { desired: Set<string>; managed: Set<string> } {
|
||||
const managed = new Set(roleMap.values());
|
||||
const desired = new Set<string>();
|
||||
|
||||
for (const group of groups) {
|
||||
const roleName = roleMap.get(normalizeGroupName(group));
|
||||
if (roleName) desired.add(roleName);
|
||||
}
|
||||
|
||||
return { desired, managed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the list of group/role names from an OIDC userInfo payload.
|
||||
*
|
||||
* When `groupClaim` is set, that claim is read first (useful for providers like
|
||||
* Zitadel that nest roles under a custom path such as
|
||||
* `urn:zitadel:iam:org:project:roles`). Otherwise the common `groups`, `roles`
|
||||
* and `group` claims are tried. Values may be an array, a comma-separated
|
||||
* string, or an object whose keys are the group names.
|
||||
*/
|
||||
export function extractOidcGroups(
|
||||
userInfo: Record<string, unknown>,
|
||||
groupClaim?: string,
|
||||
): string[] {
|
||||
let raw: unknown;
|
||||
if (groupClaim && groupClaim.trim()) {
|
||||
raw = userInfo[groupClaim.trim()];
|
||||
}
|
||||
if (raw === undefined || raw === null) {
|
||||
raw = userInfo.groups ?? userInfo.roles ?? userInfo.group;
|
||||
}
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.map(String);
|
||||
}
|
||||
if (typeof raw === "string") {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (raw && typeof raw === "object") {
|
||||
return Object.keys(raw as Record<string, unknown>);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC providers may return group claims in the ID token, userinfo response,
|
||||
* or both. Keep every verified source authoritative instead of letting a
|
||||
* sparse userinfo payload overwrite claims from the ID token.
|
||||
*/
|
||||
export function extractOidcGroupsFromSources(
|
||||
sources: Record<string, unknown>[],
|
||||
groupClaim?: string,
|
||||
): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
sources.flatMap((source) => extractOidcGroups(source, groupClaim)),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
export function isOIDCUserAllowed(
|
||||
allowedUsers: string,
|
||||
identifier: string,
|
||||
email?: string,
|
||||
): boolean {
|
||||
if (!allowedUsers || !allowedUsers.trim()) return true;
|
||||
const patterns = allowedUsers
|
||||
.split(/[\n,]/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (patterns.length === 0) return true;
|
||||
|
||||
const values = [
|
||||
identifier,
|
||||
...(email && email !== identifier ? [email] : []),
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
if (pattern === "*") return true;
|
||||
if (pattern.includes("*")) {
|
||||
const escaped = pattern
|
||||
.toLowerCase()
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*");
|
||||
const regex = new RegExp(`^${escaped}$`);
|
||||
if (values.some((v) => v && regex.test(v.toLowerCase()))) return true;
|
||||
continue;
|
||||
}
|
||||
for (const value of values) {
|
||||
if (!value) continue;
|
||||
if (pattern.toLowerCase().startsWith("@")) {
|
||||
if (value.toLowerCase().endsWith(pattern.toLowerCase())) return true;
|
||||
} else {
|
||||
if (value.toLowerCase() === pattern.toLowerCase()) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function verifyOIDCToken(
|
||||
idToken: string,
|
||||
issuerUrl: string,
|
||||
clientId: string,
|
||||
caCert?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const segments = idToken.split(".");
|
||||
if (segments.length !== 3) {
|
||||
throw new OIDCTokenFormatError(
|
||||
segments.length === 5
|
||||
? "Token is a JWE (encrypted). Termix cannot verify encrypted tokens; disable token encryption for this client in your OIDC provider."
|
||||
: `Token is not a compact JWS: expected 3 segments, got ${segments.length}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const fetchOptions = buildFetchOptions(caCert);
|
||||
const configuredIssuerUrl = issuerUrl.trim().replace(/\/+$/, "");
|
||||
const normalizedIssuerUrl = normalizeIssuer(issuerUrl);
|
||||
const possibleIssuers = [
|
||||
normalizedIssuerUrl,
|
||||
normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, ""),
|
||||
...(configuredIssuerUrl === normalizedIssuerUrl ? [issuerUrl] : []),
|
||||
];
|
||||
|
||||
const jwksUrls = [
|
||||
`${normalizedIssuerUrl}/.well-known/jwks.json`,
|
||||
`${normalizedIssuerUrl}/jwks/`,
|
||||
`${normalizedIssuerUrl.replace(/\/application\/o\/[^/]+$/, "")}/.well-known/jwks.json`,
|
||||
];
|
||||
|
||||
// Every attempt records why it failed. Without this the only thing an
|
||||
// administrator ever sees is "Failed to fetch JWKS from any URL", which
|
||||
// does not distinguish an issuer URL typo from a proxy, a private CA, or
|
||||
// a provider outage.
|
||||
const attempts: string[] = [];
|
||||
|
||||
const discoveryUrl = `${normalizedIssuerUrl}/.well-known/openid-configuration`;
|
||||
try {
|
||||
const discoveryResponse = await fetch(discoveryUrl, fetchOptions);
|
||||
if (!discoveryResponse.ok) {
|
||||
attempts.push(`${discoveryUrl}: HTTP ${discoveryResponse.status}`);
|
||||
} else {
|
||||
const discovery = (await discoveryResponse.json()) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
if (typeof discovery.jwks_uri === "string" && discovery.jwks_uri) {
|
||||
jwksUrls.unshift(discovery.jwks_uri);
|
||||
} else {
|
||||
attempts.push(`${discoveryUrl}: no jwks_uri in the discovery document`);
|
||||
}
|
||||
}
|
||||
} catch (discoveryError) {
|
||||
attempts.push(`${discoveryUrl}: ${describeFetchFailure(discoveryError)}`);
|
||||
}
|
||||
|
||||
let jwks: Record<string, unknown> | null = null;
|
||||
|
||||
for (const url of jwksUrls) {
|
||||
try {
|
||||
const response = await fetch(url, fetchOptions);
|
||||
if (!response.ok) {
|
||||
attempts.push(`${url}: HTTP ${response.status}`);
|
||||
continue;
|
||||
}
|
||||
const jwksData = (await response.json()) as Record<string, unknown>;
|
||||
if (jwksData && Array.isArray(jwksData.keys)) {
|
||||
jwks = jwksData;
|
||||
break;
|
||||
}
|
||||
attempts.push(`${url}: response contains no "keys" array`);
|
||||
} catch (error) {
|
||||
attempts.push(`${url}: ${describeFetchFailure(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!jwks) {
|
||||
throw new Error(
|
||||
`Failed to fetch JWKS from any URL. Attempts:\n ${attempts.join("\n ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!jwks.keys || !Array.isArray(jwks.keys)) {
|
||||
throw new Error(
|
||||
`Invalid JWKS response structure. Expected 'keys' array, got: ${JSON.stringify(jwks)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { decodeProtectedHeader, importJWK, jwtVerify } = await import("jose");
|
||||
const header = decodeProtectedHeader(idToken);
|
||||
const keyId = header.kid;
|
||||
|
||||
const publicKey = jwks.keys.find(
|
||||
(key: Record<string, unknown>) => key.kid === keyId,
|
||||
);
|
||||
if (!publicKey) {
|
||||
throw new Error(
|
||||
`No matching public key found for key ID: ${keyId}. Available keys: ${jwks.keys.map((k: Record<string, unknown>) => k.kid).join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const algorithm =
|
||||
typeof publicKey.alg === "string" ? publicKey.alg : header.alg;
|
||||
const key = await importJWK(publicKey, algorithm);
|
||||
|
||||
const { payload } = await jwtVerify(idToken, key, {
|
||||
issuer: possibleIssuers,
|
||||
audience: clientId,
|
||||
});
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
const GOOGLE_DEFAULTS = {
|
||||
issuer_url: "https://accounts.google.com",
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
token_url: "https://oauth2.googleapis.com/token",
|
||||
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo",
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
scopes: "openid email profile",
|
||||
};
|
||||
|
||||
const GITHUB_DEFAULTS = {
|
||||
issuer_url: "https://token.actions.githubusercontent.com",
|
||||
authorization_url: "https://github.com/login/oauth/authorize",
|
||||
token_url: "https://github.com/login/oauth/access_token",
|
||||
userinfo_url: "https://api.github.com/user",
|
||||
identifier_path: "id",
|
||||
name_path: "name",
|
||||
scopes: "read:user user:email",
|
||||
};
|
||||
|
||||
function applyProviderDefaults(
|
||||
config: OIDCConfig,
|
||||
providerType: string,
|
||||
): OIDCConfig {
|
||||
const defaults =
|
||||
providerType === "google"
|
||||
? GOOGLE_DEFAULTS
|
||||
: providerType === "github"
|
||||
? GITHUB_DEFAULTS
|
||||
: null;
|
||||
if (!defaults) return config;
|
||||
return {
|
||||
...config,
|
||||
issuer_url: config.issuer_url || defaults.issuer_url,
|
||||
authorization_url: config.authorization_url || defaults.authorization_url,
|
||||
token_url: config.token_url || defaults.token_url,
|
||||
userinfo_url: config.userinfo_url || defaults.userinfo_url,
|
||||
identifier_path: config.identifier_path || defaults.identifier_path,
|
||||
name_path: config.name_path || defaults.name_path,
|
||||
scopes: config.scopes || defaults.scopes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the provider secrets. System-key encrypted values are decrypted;
|
||||
* values still carrying a legacy base64 prefix are decoded so login keeps
|
||||
* working until the provider is next saved.
|
||||
*/
|
||||
async function decryptConfigSecret(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
return decryptSsoConfigSecrets(config);
|
||||
}
|
||||
|
||||
export async function loadProviderConfig(
|
||||
providerId: number | null | undefined,
|
||||
adminUserId?: string,
|
||||
): Promise<{
|
||||
config: OIDCConfig;
|
||||
providerType: SSOProviderType;
|
||||
providerDbId: number | null;
|
||||
} | null> {
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (envConfig && isOIDCEnvOverrideEnabled()) {
|
||||
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||
}
|
||||
|
||||
if (providerId != null) {
|
||||
try {
|
||||
const row =
|
||||
await createCurrentSsoProviderRepository().findById(providerId);
|
||||
if (row) {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(row.config);
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
if (adminUserId) {
|
||||
try {
|
||||
const adminDataKey = DataCrypto.getUserDataKey(adminUserId);
|
||||
if (adminDataKey) {
|
||||
parsed = DataCrypto.decryptRecord(
|
||||
"settings",
|
||||
parsed,
|
||||
adminUserId,
|
||||
adminDataKey,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
parsed = await decryptConfigSecret(parsed);
|
||||
}
|
||||
} else {
|
||||
parsed = await decryptConfigSecret(parsed);
|
||||
}
|
||||
const providerType = row.type as SSOProviderType;
|
||||
const config = applyProviderDefaults(
|
||||
parsed as unknown as OIDCConfig,
|
||||
providerType,
|
||||
);
|
||||
return {
|
||||
config,
|
||||
providerType,
|
||||
providerDbId: row.id,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to load SSO provider config by id", err, {
|
||||
providerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: env vars
|
||||
if (envConfig) {
|
||||
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||
}
|
||||
|
||||
// Fallback: first enabled OIDC-type provider in ssoProviders table
|
||||
try {
|
||||
const oidcRow =
|
||||
await createCurrentSsoProviderRepository().findFirstEnabledOidcLike();
|
||||
if (oidcRow) {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(oidcRow.config);
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
parsed = await decryptConfigSecret(parsed);
|
||||
const oidcProviderType = oidcRow.type as SSOProviderType;
|
||||
return {
|
||||
config: applyProviderDefaults(
|
||||
parsed as unknown as OIDCConfig,
|
||||
oidcProviderType,
|
||||
),
|
||||
providerType: oidcProviderType,
|
||||
providerDbId: oidcRow.id,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// fall through to legacy
|
||||
}
|
||||
|
||||
// Fallback: legacy settings blob
|
||||
try {
|
||||
const legacyValue =
|
||||
await createCurrentSettingsRepository().get("oidc_config");
|
||||
if (legacyValue) {
|
||||
let config = JSON.parse(legacyValue) as Record<string, unknown>;
|
||||
config = await decryptConfigSecret(config);
|
||||
return {
|
||||
config: config as unknown as OIDCConfig,
|
||||
providerType: "oidc",
|
||||
providerDbId: null,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// no legacy config
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveProviderByIssuer(issuer: string): Promise<{
|
||||
config: OIDCConfig;
|
||||
providerType: SSOProviderType;
|
||||
providerDbId: number | null;
|
||||
} | null> {
|
||||
const target = normalizeIssuer(issuer);
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
if (
|
||||
envConfig?.issuer_url &&
|
||||
isOIDCEnvOverrideEnabled() &&
|
||||
normalizeIssuer(envConfig.issuer_url) === target
|
||||
) {
|
||||
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const rows = await createCurrentSsoProviderRepository().listEnabled();
|
||||
for (const row of rows) {
|
||||
if (!["oidc", "github", "google"].includes(row.type)) continue;
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(row.config);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
parsed = await decryptConfigSecret(parsed);
|
||||
const providerType = row.type as SSOProviderType;
|
||||
const config = applyProviderDefaults(
|
||||
parsed as unknown as OIDCConfig,
|
||||
providerType,
|
||||
);
|
||||
if (config.issuer_url && normalizeIssuer(config.issuer_url) === target) {
|
||||
return { config, providerType, providerDbId: row.id };
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to resolve SSO provider by issuer", err, {
|
||||
issuer,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
envConfig?.issuer_url &&
|
||||
normalizeIssuer(envConfig.issuer_url) === target
|
||||
) {
|
||||
return { config: envConfig, providerType: "oidc", providerDbId: null };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export type LogoutTokenClaims = {
|
||||
sub: string | null;
|
||||
sid: string | null;
|
||||
jti: string;
|
||||
};
|
||||
|
||||
export function validateLogoutTokenClaims(
|
||||
payload: Record<string, unknown>,
|
||||
): LogoutTokenClaims {
|
||||
if ("nonce" in payload) {
|
||||
throw new Error("logout_token must not contain a nonce claim");
|
||||
}
|
||||
|
||||
const event = (payload.events as Record<string, unknown> | undefined)?.[
|
||||
BACKCHANNEL_LOGOUT_EVENT
|
||||
];
|
||||
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
||||
throw new Error("logout_token missing back-channel logout event");
|
||||
}
|
||||
|
||||
if (!Number.isInteger(payload.iat)) {
|
||||
throw new Error("logout_token missing iat claim");
|
||||
}
|
||||
|
||||
const jti = typeof payload.jti === "string" ? payload.jti.trim() : "";
|
||||
if (!jti) {
|
||||
throw new Error("logout_token missing jti claim");
|
||||
}
|
||||
|
||||
const sub = typeof payload.sub === "string" ? payload.sub : null;
|
||||
const sid = typeof payload.sid === "string" ? payload.sid : null;
|
||||
if (!sub && !sid) {
|
||||
throw new Error("logout_token must contain sub and/or sid");
|
||||
}
|
||||
|
||||
return { sub, sid, jti };
|
||||
}
|
||||
|
||||
export async function validateLogoutToken(
|
||||
logoutToken: string,
|
||||
config: OIDCConfig,
|
||||
): Promise<LogoutTokenClaims> {
|
||||
const payload = await verifyOIDCToken(
|
||||
logoutToken,
|
||||
config.issuer_url,
|
||||
config.client_id,
|
||||
config.ca_cert,
|
||||
);
|
||||
|
||||
return validateLogoutTokenClaims(payload);
|
||||
}
|
||||
@@ -5,9 +5,7 @@ import { nanoid } from "nanoid";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { parseUserAgent } from "../../utils/user-agent-parser.js";
|
||||
import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js";
|
||||
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||
import {
|
||||
isLoopbackRequest,
|
||||
@@ -17,12 +15,6 @@ import {
|
||||
} from "./desktop-auto-session.js";
|
||||
import { shouldShowDonationModal } from "./donation-modal-utils.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import {
|
||||
getOIDCConfigFromEnv,
|
||||
loadProviderConfig,
|
||||
resolveProviderByIssuer,
|
||||
validateLogoutToken,
|
||||
} from "./user-oidc-utils.js";
|
||||
import { registerUserApiKeyRoutes } from "./user-api-key-routes.js";
|
||||
import { registerBrandingRoutes } from "./branding-routes.js";
|
||||
import { registerUserSettingsRoutes } from "./user-settings-routes.js";
|
||||
@@ -32,15 +24,13 @@ import { registerUserOidcAccountRoutes } from "./user-oidc-account-routes.js";
|
||||
import { registerUserPasswordResetRoutes } from "./user-password-reset-routes.js";
|
||||
import { registerUserAdminRoutes } from "./user-admin-routes.js";
|
||||
import { registerUserDataAccessRoutes } from "./user-data-access-routes.js";
|
||||
import { registerSSOProviderRoutes } from "./sso-provider-routes.js";
|
||||
import { registerLDAPAuthRoutes } from "./ldap-auth-routes.js";
|
||||
import { registerAuthRoutes } from "./auth-routes.js";
|
||||
import { listExternalLoginMethods, registerAuthRoutes } from "./auth-routes.js";
|
||||
import { registerAuthCompatRoutes } from "./auth-compat-routes.js";
|
||||
import { logAudit, getRequestMeta } from "../../utils/audit-logger.js";
|
||||
import {
|
||||
createCurrentSettingsRepository,
|
||||
getCurrentSettingValue,
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSsoProviderRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../repositories/factory.js";
|
||||
@@ -54,18 +44,7 @@ import {
|
||||
|
||||
import { getPasswordLoginStatus } from "../../auth/core-auth.js";
|
||||
import { verifyPasswordLogin } from "../../auth/builtin-login-methods.js";
|
||||
import {
|
||||
redirectWithLoginError,
|
||||
respondWithLogin,
|
||||
respondWithRedirectLogin,
|
||||
sendLoginError,
|
||||
} from "../../auth/login-pipeline.js";
|
||||
import {
|
||||
handleOidcCallback,
|
||||
RedirectLoginError,
|
||||
startOidcLogin,
|
||||
} from "../../auth/legacy/oidc-login.js";
|
||||
import { LoginMethodError } from "../../auth/types.js";
|
||||
import { respondWithLogin, sendLoginError } from "../../auth/login-pipeline.js";
|
||||
import {
|
||||
isNativeAppRequest,
|
||||
syncSharedCredentialsForUserRoles,
|
||||
@@ -297,365 +276,6 @@ router.post("/create", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-config:
|
||||
* post:
|
||||
* summary: Configure OIDC provider
|
||||
* description: Creates or updates the OIDC provider configuration.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OIDC configuration updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update OIDC config.
|
||||
*/
|
||||
router.post("/oidc-config", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = await requireCurrentAdmin(userId);
|
||||
if (!user) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
const {
|
||||
client_id,
|
||||
client_secret,
|
||||
issuer_url,
|
||||
authorization_url,
|
||||
token_url,
|
||||
userinfo_url,
|
||||
identifier_path,
|
||||
name_path,
|
||||
scopes,
|
||||
allowed_users,
|
||||
admin_group,
|
||||
group_claim,
|
||||
} = req.body;
|
||||
|
||||
const isDisableRequest =
|
||||
(client_id === "" || client_id === null || client_id === undefined) &&
|
||||
(client_secret === "" ||
|
||||
client_secret === null ||
|
||||
client_secret === undefined) &&
|
||||
(issuer_url === "" || issuer_url === null || issuer_url === undefined) &&
|
||||
(authorization_url === "" ||
|
||||
authorization_url === null ||
|
||||
authorization_url === undefined) &&
|
||||
(token_url === "" || token_url === null || token_url === undefined);
|
||||
|
||||
const isEnableRequest =
|
||||
isNonEmptyString(client_id) &&
|
||||
isNonEmptyString(client_secret) &&
|
||||
isNonEmptyString(issuer_url) &&
|
||||
isNonEmptyString(authorization_url) &&
|
||||
isNonEmptyString(token_url) &&
|
||||
isNonEmptyString(identifier_path) &&
|
||||
isNonEmptyString(name_path);
|
||||
|
||||
if (!isDisableRequest && !isEnableRequest) {
|
||||
authLogger.warn(
|
||||
"OIDC validation failed - neither disable nor enable request",
|
||||
{
|
||||
operation: "oidc_config_update",
|
||||
userId,
|
||||
isDisableRequest,
|
||||
isEnableRequest,
|
||||
},
|
||||
);
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "All OIDC configuration fields are required" });
|
||||
}
|
||||
|
||||
const settingsRepository = createCurrentSettingsRepository();
|
||||
|
||||
if (isDisableRequest) {
|
||||
await settingsRepository.delete("oidc_config");
|
||||
authLogger.info("OIDC configuration disabled", {
|
||||
operation: "oidc_disable",
|
||||
userId,
|
||||
});
|
||||
res.json({ message: "OIDC configuration disabled" });
|
||||
} else {
|
||||
const config = {
|
||||
client_id,
|
||||
client_secret,
|
||||
issuer_url,
|
||||
authorization_url,
|
||||
token_url,
|
||||
userinfo_url: userinfo_url || "",
|
||||
identifier_path,
|
||||
name_path,
|
||||
scopes: scopes || "openid email profile",
|
||||
allowed_users: allowed_users || "",
|
||||
admin_group: admin_group || "",
|
||||
group_claim: group_claim || "",
|
||||
};
|
||||
|
||||
let encryptedConfig;
|
||||
try {
|
||||
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (adminDataKey) {
|
||||
const configWithId = { ...config, id: `oidc-config-${userId}` };
|
||||
encryptedConfig = DataCrypto.encryptRecord(
|
||||
"settings",
|
||||
configWithId,
|
||||
userId,
|
||||
adminDataKey,
|
||||
);
|
||||
} else {
|
||||
encryptedConfig = {
|
||||
...config,
|
||||
client_secret: `encrypted:${Buffer.from(client_secret).toString("base64")}`,
|
||||
};
|
||||
authLogger.warn(
|
||||
"OIDC configuration stored with basic encoding - admin should re-save with password",
|
||||
{
|
||||
operation: "oidc_config_basic_encoding",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (encryptError) {
|
||||
authLogger.error(
|
||||
"Failed to encrypt OIDC configuration, storing with basic encoding",
|
||||
encryptError,
|
||||
{
|
||||
operation: "oidc_config_encrypt_failed",
|
||||
userId,
|
||||
},
|
||||
);
|
||||
encryptedConfig = {
|
||||
...config,
|
||||
client_secret: `encoded:${Buffer.from(client_secret).toString("base64")}`,
|
||||
};
|
||||
}
|
||||
|
||||
await settingsRepository.set(
|
||||
"oidc_config",
|
||||
JSON.stringify(encryptedConfig),
|
||||
);
|
||||
authLogger.info("OIDC configuration updated", {
|
||||
operation: "oidc_update",
|
||||
userId,
|
||||
hasUserinfoUrl: !!userinfo_url,
|
||||
});
|
||||
res.json({ message: "OIDC configuration updated" });
|
||||
}
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update OIDC config", err);
|
||||
res.status(500).json({ error: "Failed to update OIDC config" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-config:
|
||||
* delete:
|
||||
* summary: Disable OIDC configuration
|
||||
* description: Disables the OIDC provider configuration.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OIDC configuration disabled.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to disable OIDC config.
|
||||
*/
|
||||
router.delete("/oidc-config", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = await requireCurrentAdmin(userId);
|
||||
if (!user) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
|
||||
await createCurrentSettingsRepository().delete("oidc_config");
|
||||
authLogger.success("OIDC configuration disabled", {
|
||||
operation: "oidc_disable",
|
||||
userId,
|
||||
});
|
||||
res.json({ message: "OIDC configuration disabled" });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to disable OIDC config", err);
|
||||
res.status(500).json({ error: "Failed to disable OIDC config" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-config:
|
||||
* get:
|
||||
* summary: Get OIDC configuration
|
||||
* description: Returns the public OIDC configuration.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Public OIDC configuration.
|
||||
* 500:
|
||||
* description: Failed to get OIDC config.
|
||||
*/
|
||||
router.get("/oidc-config", async (_req, res) => {
|
||||
try {
|
||||
const providerResult = await loadProviderConfig(undefined);
|
||||
if (!providerResult) {
|
||||
return res.json(null);
|
||||
}
|
||||
const { config } = providerResult;
|
||||
return res.json({
|
||||
client_id: config.client_id,
|
||||
issuer_url: config.issuer_url,
|
||||
authorization_url: config.authorization_url,
|
||||
scopes: config.scopes,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get OIDC config", err);
|
||||
res.status(500).json({ error: "Failed to get OIDC config" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc-config/admin:
|
||||
* get:
|
||||
* summary: Get OIDC configuration for admin
|
||||
* description: Returns the full OIDC configuration for an admin.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Full OIDC configuration.
|
||||
* 500:
|
||||
* description: Failed to get OIDC config for admin.
|
||||
*/
|
||||
router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const value = await createCurrentSettingsRepository().get("oidc_config");
|
||||
if (!value) {
|
||||
const envConfig = getOIDCConfigFromEnv();
|
||||
return res.json(envConfig);
|
||||
}
|
||||
|
||||
let config = JSON.parse(value);
|
||||
|
||||
if (config.client_secret?.startsWith("encrypted:")) {
|
||||
try {
|
||||
const adminDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (adminDataKey) {
|
||||
config = DataCrypto.decryptRecord(
|
||||
"settings",
|
||||
config,
|
||||
userId,
|
||||
adminDataKey,
|
||||
);
|
||||
} else {
|
||||
config.client_secret = "[ENCRYPTED - PASSWORD REQUIRED]";
|
||||
}
|
||||
} catch {
|
||||
authLogger.warn("Failed to decrypt OIDC config for admin", {
|
||||
operation: "oidc_config_decrypt_failed",
|
||||
userId,
|
||||
});
|
||||
config.client_secret = "[ENCRYPTED - DECRYPTION FAILED]";
|
||||
}
|
||||
} else if (config.client_secret?.startsWith("encoded:")) {
|
||||
try {
|
||||
const decoded = Buffer.from(
|
||||
config.client_secret.substring(8),
|
||||
"base64",
|
||||
).toString("utf8");
|
||||
config.client_secret = decoded;
|
||||
} catch {
|
||||
authLogger.warn("Failed to decode OIDC config for admin", {
|
||||
operation: "oidc_config_decode_failed",
|
||||
userId,
|
||||
});
|
||||
config.client_secret = "[ENCODING ERROR]";
|
||||
}
|
||||
}
|
||||
|
||||
res.json(config);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get OIDC config for admin", err);
|
||||
res.status(500).json({ error: "Failed to get OIDC config for admin" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc/authorize:
|
||||
* get:
|
||||
* summary: Get OIDC authorization URL
|
||||
* description: Returns the OIDC authorization URL.
|
||||
* tags:
|
||||
* - Users
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: rememberMe
|
||||
* schema:
|
||||
* type: boolean
|
||||
* description: Whether to extend the session to 30 days instead of 2 hours.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OIDC authorization URL.
|
||||
* 404:
|
||||
* description: OIDC not configured.
|
||||
* 500:
|
||||
* description: Failed to generate authorization URL.
|
||||
*/
|
||||
router.get("/oidc/authorize", async (req, res) => {
|
||||
try {
|
||||
res.json(await startOidcLogin(req));
|
||||
} catch (err) {
|
||||
if (err instanceof LoginMethodError) {
|
||||
return res.status(err.status).json({ error: err.message });
|
||||
}
|
||||
authLogger.error("Failed to generate OIDC auth URL", err);
|
||||
res.status(500).json({ error: "Failed to generate authorization URL" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/oidc/callback:
|
||||
* get:
|
||||
* summary: OIDC callback
|
||||
* description: Handles the OIDC callback, exchanges the code for a token, and creates or logs in the user.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 302:
|
||||
* description: Redirects to the frontend with a success or error message.
|
||||
* 400:
|
||||
* description: Code and state are required.
|
||||
*/
|
||||
router.get("/oidc/callback", async (req, res) => {
|
||||
let identity;
|
||||
try {
|
||||
identity = await handleOidcCallback(req);
|
||||
} catch (error) {
|
||||
if (error instanceof RedirectLoginError) {
|
||||
return redirectWithLoginError(res, error.returnTo, error);
|
||||
}
|
||||
return sendLoginError(res, error);
|
||||
}
|
||||
await respondWithRedirectLogin(
|
||||
req,
|
||||
res,
|
||||
identity,
|
||||
{ methodId: "oidc", rememberMe: !!identity.rememberMe },
|
||||
isOidcTokenCallback,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/proxy-login:
|
||||
@@ -728,17 +348,11 @@ router.post("/proxy-login", async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const [legacyOidc, enabledProviders, userRecord] = await Promise.all([
|
||||
createCurrentSettingsRepository().get("oidc_config"),
|
||||
createCurrentSsoProviderRepository().listEnabled(),
|
||||
const [externalMethods, userRecord] = await Promise.all([
|
||||
listExternalLoginMethods(),
|
||||
createCurrentUserRepository().findByUsername(username),
|
||||
]);
|
||||
const hasOidc =
|
||||
Boolean(getOIDCConfigFromEnv() || legacyOidc) ||
|
||||
enabledProviders.some((provider) =>
|
||||
["oidc", "github", "google"].includes(provider.type),
|
||||
);
|
||||
if (hasOidc) {
|
||||
if (externalMethods.length > 0) {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ error: "Proxy authentication cannot be used with OIDC" });
|
||||
@@ -931,84 +545,6 @@ router.post("/logout", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
const seenLogoutJti = new Map<string, number>();
|
||||
const LOGOUT_JTI_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function pruneLogoutJti(now: number): void {
|
||||
for (const [key, expiry] of seenLogoutJti) {
|
||||
if (expiry <= now) seenLogoutJti.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function isReplayedJti(jti: string): boolean {
|
||||
const now = Date.now();
|
||||
pruneLogoutJti(now);
|
||||
return seenLogoutJti.has(jti);
|
||||
}
|
||||
|
||||
function markLogoutJti(jti: string): void {
|
||||
const now = Date.now();
|
||||
pruneLogoutJti(now);
|
||||
seenLogoutJti.set(jti, now + LOGOUT_JTI_TTL_MS);
|
||||
}
|
||||
|
||||
router.post("/oidc/backchannel-logout", async (req, res) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
|
||||
try {
|
||||
const logoutToken = (req.body as Record<string, unknown> | undefined)
|
||||
?.logout_token;
|
||||
if (typeof logoutToken !== "string" || !logoutToken) {
|
||||
return res.status(400).json({ error: "missing logout_token" });
|
||||
}
|
||||
|
||||
let issuer: string | null = null;
|
||||
try {
|
||||
const parts = logoutToken.split(".");
|
||||
if (parts.length === 3) {
|
||||
const claims = JSON.parse(Buffer.from(parts[1], "base64").toString());
|
||||
issuer = typeof claims.iss === "string" ? claims.iss : null;
|
||||
}
|
||||
} catch {
|
||||
issuer = null;
|
||||
}
|
||||
|
||||
if (!issuer) {
|
||||
return res.status(400).json({ error: "invalid logout_token" });
|
||||
}
|
||||
|
||||
const provider = await resolveProviderByIssuer(issuer);
|
||||
if (!provider) {
|
||||
authLogger.warn("Back-channel logout for unknown issuer", { issuer });
|
||||
return res.status(400).json({ error: "unknown issuer" });
|
||||
}
|
||||
|
||||
const claims = await validateLogoutToken(logoutToken, provider.config);
|
||||
|
||||
if (claims.jti && isReplayedJti(claims.jti)) {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
try {
|
||||
await authManager.revokeSessionsByOidc({
|
||||
ssoProviderId: provider.providerDbId,
|
||||
sub: claims.sub,
|
||||
sid: claims.sid,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("OIDC back-channel session revocation failed", err);
|
||||
return res.status(500).json({ error: "logout processing failed" });
|
||||
}
|
||||
|
||||
markLogoutJti(claims.jti);
|
||||
|
||||
return res.status(200).json({ ok: true });
|
||||
} catch (err) {
|
||||
authLogger.error("OIDC back-channel logout failed", err);
|
||||
return res.status(400).json({ error: "invalid logout_token" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/me:
|
||||
@@ -2117,8 +1653,7 @@ registerAcmeSSLRoutes(router, authenticateJWT);
|
||||
registerUserApiKeyRoutes(router, requireAdmin);
|
||||
registerBrandingRoutes(router, requireAdmin);
|
||||
|
||||
registerSSOProviderRoutes(router);
|
||||
registerLDAPAuthRoutes(router);
|
||||
registerAuthRoutes(router);
|
||||
registerAuthCompatRoutes(router);
|
||||
|
||||
export default router;
|
||||
@@ -56,20 +56,18 @@ export async function expandOidcUsername(
|
||||
|
||||
const match = /^ldap:(\d+):(.+)$/.exec(oidcIdentifier);
|
||||
if (match) {
|
||||
// Make sure the SSO provider is actually LDAP, to prevent spoofing.
|
||||
const { createCurrentSsoProviderRepository } =
|
||||
// Only strip the prefix for a real LDAP identity, to prevent spoofing
|
||||
// through an SSO subject that happens to look like one.
|
||||
const { createCurrentUserAuthRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
const claimedProviderId = Number(match[1]);
|
||||
const provider =
|
||||
user?.ssoProviderId != null
|
||||
? await createCurrentSsoProviderRepository().findById(
|
||||
user.ssoProviderId,
|
||||
)
|
||||
: null;
|
||||
|
||||
const identities =
|
||||
await createCurrentUserAuthRepository().listIdentitiesForUser(userId);
|
||||
if (
|
||||
provider?.type === "ldap" &&
|
||||
user?.ssoProviderId === claimedProviderId
|
||||
identities.some(
|
||||
(identity) =>
|
||||
identity.providerId === `ldap:${match[1]}` &&
|
||||
identity.subject === match[2],
|
||||
)
|
||||
) {
|
||||
oidcIdentifier = match[2];
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import type {
|
||||
PluginAuth,
|
||||
PluginVerifiedIdentity,
|
||||
PluginSsh,
|
||||
PluginSshConnectOptions,
|
||||
PluginSshHost,
|
||||
@@ -28,7 +29,12 @@ import {
|
||||
} from "../hosts/connect/auth-provider-registry.js";
|
||||
import { classifyKeyboardInteractive } from "../hosts/connect/keyboard-interactive.js";
|
||||
import { ensureCoreSshAuthProviders } from "../hosts/connect/core-providers.js";
|
||||
import { registerLoginMethod, registerSecondFactor } from "../auth/registry.js";
|
||||
import {
|
||||
getLoginMethod,
|
||||
registerLoginMethod,
|
||||
registerSecondFactor,
|
||||
} from "../auth/registry.js";
|
||||
import type { VerifiedIdentity } from "../auth/types.js";
|
||||
import type {
|
||||
MutableConnectConfig,
|
||||
SshAuthProvider,
|
||||
@@ -392,6 +398,60 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns what a plugin method returned into core's identity. Only the fields
|
||||
* the SDK documents are copied, so a plugin cannot set core-only extras, and
|
||||
* its rate limit key is kept apart from core's and other plugins'.
|
||||
*/
|
||||
export function toCoreIdentity(
|
||||
pluginId: string,
|
||||
identity: PluginVerifiedIdentity,
|
||||
): VerifiedIdentity {
|
||||
if (identity.kind === "user") {
|
||||
return {
|
||||
kind: "user",
|
||||
userId: identity.userId,
|
||||
mfaSatisfied: identity.mfaSatisfied,
|
||||
password: identity.password,
|
||||
returnTo: identity.returnTo,
|
||||
rememberMe: identity.rememberMe,
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "external",
|
||||
provider: identity.provider,
|
||||
subject: identity.subject,
|
||||
email: identity.email,
|
||||
name: identity.name,
|
||||
groups: identity.groups,
|
||||
isAdmin: identity.isAdmin,
|
||||
allowedUsers: identity.allowedUsers,
|
||||
mfaSatisfied: identity.mfaSatisfied,
|
||||
returnTo: identity.returnTo,
|
||||
rememberMe: identity.rememberMe,
|
||||
legacyIdentifier: identity.legacy?.identifier,
|
||||
ssoProviderId:
|
||||
identity.logoutClaims?.providerId ??
|
||||
identity.legacy?.providerRowId ??
|
||||
null,
|
||||
oidcSub: identity.logoutClaims?.sub ?? null,
|
||||
oidcSid: identity.logoutClaims?.sid ?? null,
|
||||
roleSync: identity.roles
|
||||
? {
|
||||
desired: [...identity.roles.desired],
|
||||
managed: [...identity.roles.managed],
|
||||
}
|
||||
: undefined,
|
||||
rateLimitUsername: identity.rateLimitKey
|
||||
? rateLimitKeyFor(pluginId, identity.rateLimitKey)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function rateLimitKeyFor(pluginId: string, key: string): string {
|
||||
return `plugin:${pluginId}:${key}`;
|
||||
}
|
||||
|
||||
export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
const pluginId = manifest.id;
|
||||
const declared = manifest.capabilities;
|
||||
@@ -477,13 +537,16 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
callback: method.callback
|
||||
? async (...args) => {
|
||||
await granted();
|
||||
return method.callback!(...args);
|
||||
return toCoreIdentity(
|
||||
pluginId,
|
||||
await method.callback!(...args),
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
verify: method.verify
|
||||
? async (...args) => {
|
||||
await granted();
|
||||
return method.verify!(...args);
|
||||
return toCoreIdentity(pluginId, await method.verify!(...args));
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
@@ -550,5 +613,61 @@ export function createPluginAuth({ manifest, bag, audit }: Deps): PluginAuth {
|
||||
success: true,
|
||||
});
|
||||
},
|
||||
|
||||
completeRedirectLogin: async (methodId, req, res) => {
|
||||
requireDeclared(contributes.loginMethods, methodId, "loginMethods");
|
||||
await granted();
|
||||
const method = getLoginMethod(methodId);
|
||||
if (!method || method.pluginId !== pluginId) {
|
||||
throw new Error(
|
||||
`Plugin ${pluginId} has no registered login method "${methodId}"`,
|
||||
);
|
||||
}
|
||||
const { handleRedirectCallback } =
|
||||
await import("../database/routes/auth-routes.js");
|
||||
await handleRedirectCallback(method, req as never, res as never);
|
||||
},
|
||||
|
||||
revokeSessions: async (match) => {
|
||||
await granted();
|
||||
if (!match.sub && !match.sid) return 0;
|
||||
const { AuthManager } = await import("../utils/auth-manager.js");
|
||||
const revoked = await AuthManager.getInstance().revokeSessionsByOidc({
|
||||
ssoProviderId: match.providerId ?? null,
|
||||
sub: match.sub ?? null,
|
||||
sid: match.sid ?? null,
|
||||
});
|
||||
await audit(
|
||||
"auth_sessions_revoked",
|
||||
`${revoked} session(s) for provider ${match.providerId ?? "none"}`,
|
||||
{ success: true },
|
||||
);
|
||||
return revoked;
|
||||
},
|
||||
|
||||
loginRateLimit: {
|
||||
isLocked: async (ip, key) => {
|
||||
await granted();
|
||||
const { loginRateLimiter } =
|
||||
await import("../utils/login-rate-limiter.js");
|
||||
return loginRateLimiter.isLocked(ip, rateLimitKeyFor(pluginId, key));
|
||||
},
|
||||
recordFailure: async (ip, key) => {
|
||||
await granted();
|
||||
const { loginRateLimiter } =
|
||||
await import("../utils/login-rate-limiter.js");
|
||||
loginRateLimiter.recordFailedAttempt(
|
||||
ip,
|
||||
rateLimitKeyFor(pluginId, key),
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
countLinkedUsers: async (provider) => {
|
||||
await granted();
|
||||
const { createCurrentUserAuthRepository } =
|
||||
await import("../database/repositories/factory.js");
|
||||
return createCurrentUserAuthRepository().countUsersForProvider(provider);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import { getRequestBaseUrlWithForceHTTPS } from "../utils/request-origin.js";
|
||||
import { pluginEvents } from "./events.js";
|
||||
import * as registry from "./registry.js";
|
||||
import * as serviceRegistry from "./service-registry.js";
|
||||
@@ -683,6 +684,10 @@ export function createPluginContext(
|
||||
);
|
||||
return router as never;
|
||||
},
|
||||
baseUrl: (req) =>
|
||||
getRequestBaseUrlWithForceHTTPS(
|
||||
req as Parameters<typeof getRequestBaseUrlWithForceHTTPS>[0],
|
||||
),
|
||||
},
|
||||
|
||||
ws: {
|
||||
|
||||
@@ -343,7 +343,13 @@ export class PluginLoader {
|
||||
// than leave it half-running against a schema that is not there. A
|
||||
// throw here lands in the catch below, which fails this plugin only.
|
||||
const { migratePlugin } = await import("./data.js");
|
||||
await migratePlugin(plugin.id, plugin.dir);
|
||||
const applied = await migratePlugin(plugin.id, plugin.dir);
|
||||
// A new table may be where core data is waiting to move.
|
||||
if (applied.length > 0) {
|
||||
const { runPluginDataMoves } =
|
||||
await import("../utils/crypto-migration/plugin-data-moves.js");
|
||||
await runPluginDataMoves();
|
||||
}
|
||||
|
||||
const handle = createPluginHandle(plugin.id, { activate, deactivate });
|
||||
const ctx = createPluginContext(plugin.manifest, handle);
|
||||
|
||||
+9
-22
@@ -197,31 +197,18 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
|
||||
});
|
||||
|
||||
if (trustedProxyAuth.enabled) {
|
||||
const {
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentSsoProviderRepository,
|
||||
createCurrentUserAuthRepository,
|
||||
createCurrentUserRepository,
|
||||
} = await import("./database/repositories/factory.js");
|
||||
const [legacyOidc, providers, users, secondFactorUsers] =
|
||||
await Promise.all([
|
||||
createCurrentSettingsRepository().get("oidc_config"),
|
||||
createCurrentSsoProviderRepository().listEnabled(),
|
||||
createCurrentUserRepository().listAll(),
|
||||
createCurrentUserAuthRepository().listUserIdsWithSecondFactors(),
|
||||
]);
|
||||
const conflictingProvider = providers.some((provider) =>
|
||||
["oidc", "github", "google"].includes(provider.type),
|
||||
);
|
||||
// Enabled external login methods are refused at runtime instead: their
|
||||
// plugins are not running yet at this point.
|
||||
const { createCurrentUserAuthRepository, createCurrentUserRepository } =
|
||||
await import("./database/repositories/factory.js");
|
||||
const [users, secondFactorUsers] = await Promise.all([
|
||||
createCurrentUserRepository().listAll(),
|
||||
createCurrentUserAuthRepository().listUserIdsWithSecondFactors(),
|
||||
]);
|
||||
const conflictingUser = users.some(
|
||||
(user) => user.isOidc || secondFactorUsers.has(user.id),
|
||||
);
|
||||
if (
|
||||
legacyOidc ||
|
||||
process.env.OIDC_CLIENT_ID ||
|
||||
conflictingProvider ||
|
||||
conflictingUser
|
||||
) {
|
||||
if (process.env.OIDC_CLIENT_ID || conflictingUser) {
|
||||
throw new Error(
|
||||
"Trusted proxy authentication cannot start while OIDC or a second factor is enabled",
|
||||
);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isExternalUserAllowed } from "../../auth/allowed-users.js";
|
||||
|
||||
describe("isExternalUserAllowed", () => {
|
||||
it("allows everyone when the allow-list is empty", () => {
|
||||
expect(isExternalUserAllowed("", "alice", "alice@x.com")).toBe(true);
|
||||
expect(isExternalUserAllowed(" ", "alice")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows everyone with the '*' wildcard", () => {
|
||||
expect(isExternalUserAllowed("*", "anyone", "anyone@x.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches an exact identifier (case-insensitive)", () => {
|
||||
expect(isExternalUserAllowed("alice,bob", "alice")).toBe(true);
|
||||
expect(isExternalUserAllowed("Alice", "alice")).toBe(true);
|
||||
expect(isExternalUserAllowed("alice", "ALICE")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches against the email as well as the identifier", () => {
|
||||
expect(isExternalUserAllowed("alice@x.com", "sub-123", "alice@x.com")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("matches an @domain suffix pattern", () => {
|
||||
expect(
|
||||
isExternalUserAllowed("@company.com", "sub-1", "bob@company.com"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isExternalUserAllowed("@company.com", "sub-1", "bob@COMPANY.COM"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("denies users not on the list", () => {
|
||||
expect(isExternalUserAllowed("alice,bob", "charlie", "charlie@x.com")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
isExternalUserAllowed("@company.com", "sub-1", "bob@other.com"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores blank entries and surrounding whitespace in the list", () => {
|
||||
expect(isExternalUserAllowed(" alice , , bob ", "bob")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match the email against an identifier-only pattern when email differs", () => {
|
||||
expect(isExternalUserAllowed("alice", "sub-123", "alice@x.com")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("matches *@domain.com wildcard pattern against emails", () => {
|
||||
expect(
|
||||
isExternalUserAllowed("*@company.com", "sub-1", "john@company.com"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isExternalUserAllowed("*@company.com", "sub-1", "jane@COMPANY.COM"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isExternalUserAllowed("*@company.com", "sub-1", "user@other.com"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("matches glob patterns with multiple wildcards", () => {
|
||||
expect(isExternalUserAllowed("admin*", "admin_user")).toBe(true);
|
||||
expect(isExternalUserAllowed("admin*", "user_admin")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,12 @@ export function fakeFactory(state: AuthState) {
|
||||
email: input.email ?? null,
|
||||
});
|
||||
},
|
||||
countUsersForProvider: async (providerId: string) =>
|
||||
new Set(
|
||||
state.identities
|
||||
.filter((row) => row.providerId === providerId)
|
||||
.map((row) => row.userId),
|
||||
).size,
|
||||
listSecondFactors: async (userId: string) =>
|
||||
state.factors.filter((row) => row.userId === userId),
|
||||
hasSecondFactor: async (userId: string) =>
|
||||
@@ -179,9 +185,6 @@ export function fakeFactory(state: AuthState) {
|
||||
createCurrentTrustedDeviceRepository: () => ({
|
||||
deleteByUserId: async () => {},
|
||||
}),
|
||||
createCurrentSsoProviderRepository: () => ({
|
||||
listEnabledPublic: async () => [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -202,6 +205,7 @@ export function fakeAuthManager(state: AuthState) {
|
||||
}),
|
||||
authenticateUser: vi.fn(async () => true),
|
||||
authenticateOIDCUser: vi.fn(async () => true),
|
||||
revokeSessionsByOidc: vi.fn(async () => 1),
|
||||
unlockWithSystemKey: vi.fn(async () => true),
|
||||
registerOIDCUser: vi.fn(async () => {}),
|
||||
isTrustedDevice: vi.fn(async (userId: string, fingerprint: string) =>
|
||||
|
||||
@@ -1,425 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createAuthState,
|
||||
fakeAuthManager,
|
||||
fakeRequest,
|
||||
fakeResponse,
|
||||
type AuthState,
|
||||
} from "./auth-test-helpers.js";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
state: null as unknown as AuthState,
|
||||
manager: null as unknown as ReturnType<
|
||||
typeof import("./auth-test-helpers.js").fakeAuthManager
|
||||
>,
|
||||
providerConfig: null as unknown,
|
||||
providerType: "oidc",
|
||||
idTokenClaims: null as unknown,
|
||||
ldap: {
|
||||
users: {} as Record<
|
||||
string,
|
||||
{ dn: string; password: string; attrs: Record<string, string> }
|
||||
>,
|
||||
adminDns: [] as string[],
|
||||
},
|
||||
ssoProvider: { id: 4, type: "ldap", enabled: true },
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", async () => {
|
||||
const helpers = await import("./auth-test-helpers.js");
|
||||
const names = [
|
||||
...Object.keys(helpers.fakeFactory(helpers.createAuthState())),
|
||||
];
|
||||
const mocked = Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
(...args: unknown[]) =>
|
||||
(
|
||||
helpers.fakeFactory(h.state) as Record<
|
||||
string,
|
||||
(...a: unknown[]) => unknown
|
||||
>
|
||||
)[name](...args),
|
||||
]),
|
||||
);
|
||||
return {
|
||||
...mocked,
|
||||
createCurrentSsoProviderRepository: () => ({
|
||||
findById: async () => h.ssoProvider,
|
||||
listEnabledPublic: async () => [],
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: { getInstance: () => h.manager },
|
||||
}));
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
h.state.audits.push(entry);
|
||||
},
|
||||
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
vi.mock("../../hosts/internal-events.js", () => ({
|
||||
emitInternalEvent: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
return { authLogger: log, databaseLogger: log, sshLogger: log, logger: log };
|
||||
});
|
||||
vi.mock("../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
|
||||
}));
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({ invalidateUserPermissionCache: vi.fn() }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
|
||||
SharedCredentialSecretsManager: {
|
||||
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/routes/user-oidc-utils.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("../../database/routes/user-oidc-utils.js")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
loadProviderConfig: async () =>
|
||||
h.providerConfig
|
||||
? {
|
||||
config: h.providerConfig,
|
||||
providerType: h.providerType,
|
||||
providerDbId: 3,
|
||||
}
|
||||
: null,
|
||||
verifyOIDCToken: async () => h.idTokenClaims,
|
||||
};
|
||||
});
|
||||
vi.mock("ldapjs", () => {
|
||||
function createClient() {
|
||||
let bound: string | null = null;
|
||||
return {
|
||||
bind: (dn: string, password: string, cb: (err?: Error) => void) => {
|
||||
if (dn === "cn=service") {
|
||||
bound = dn;
|
||||
return cb();
|
||||
}
|
||||
const user = Object.values(h.ldap.users).find((u) => u.dn === dn);
|
||||
if (user && user.password === password) {
|
||||
bound = dn;
|
||||
return cb();
|
||||
}
|
||||
cb(new Error("invalid credentials"));
|
||||
},
|
||||
search: (
|
||||
base: string,
|
||||
options: { filter: string },
|
||||
cb: (err: Error | null, res: unknown) => void,
|
||||
) => {
|
||||
const listeners: Record<string, (value?: unknown) => void> = {};
|
||||
const res = {
|
||||
on: (event: string, fn: (value?: unknown) => void) => {
|
||||
listeners[event] = fn;
|
||||
},
|
||||
};
|
||||
cb(null, res);
|
||||
queueMicrotask(() => {
|
||||
if (base === "ou=groups") {
|
||||
for (const dn of h.ldap.adminDns) {
|
||||
if (options.filter.includes(dn)) {
|
||||
listeners.searchEntry?.({
|
||||
dn: { toString: () => "cn=admins,ou=groups" },
|
||||
attributes: [{ type: "cn", values: ["admins"] }],
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const match = /uid=([^)]+)/.exec(options.filter);
|
||||
const user = match ? h.ldap.users[match[1]] : undefined;
|
||||
if (user) {
|
||||
listeners.searchEntry?.({
|
||||
dn: { toString: () => user.dn },
|
||||
attributes: Object.entries(user.attrs).map(([type, value]) => ({
|
||||
type,
|
||||
values: [value],
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
listeners.end?.();
|
||||
});
|
||||
void bound;
|
||||
},
|
||||
unbind: () => {},
|
||||
};
|
||||
}
|
||||
return { default: { createClient }, createClient };
|
||||
});
|
||||
|
||||
const { handleOidcCallback } = await import("../../auth/legacy/oidc-login.js");
|
||||
const { verifyLdapLogin } = await import("../../auth/legacy/ldap-login.js");
|
||||
const { respondWithLogin, respondWithRedirectLogin, runLogin } =
|
||||
await import("../../auth/login-pipeline.js");
|
||||
|
||||
beforeEach(() => {
|
||||
h.state = createAuthState();
|
||||
h.manager = fakeAuthManager(h.state);
|
||||
h.providerType = "oidc";
|
||||
h.providerConfig = {
|
||||
client_id: "client",
|
||||
client_secret: "secret",
|
||||
issuer_url: "https://idp.example",
|
||||
authorization_url: "https://idp.example/auth",
|
||||
token_url: "https://idp.example/token",
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
scopes: "openid",
|
||||
admin_group: "termix-admins",
|
||||
group_claim: "groups",
|
||||
};
|
||||
h.idTokenClaims = {
|
||||
sub: "sub-1",
|
||||
sid: "sid-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
nonce: "nonce-1",
|
||||
groups: ["termix-admins"],
|
||||
};
|
||||
for (const [key, value] of Object.entries({
|
||||
oidc_backend_callback_state1: "https://termix.example/users/oidc/callback",
|
||||
oidc_frontend_origin_state1: "https://termix.example",
|
||||
oidc_remember_me_state1: "false",
|
||||
oidc_state_state1: "nonce-1",
|
||||
oidc_provider_state1: "3",
|
||||
})) {
|
||||
h.state.settings.set(key, value);
|
||||
}
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (url === "https://idp.example/token") {
|
||||
return new Response(
|
||||
JSON.stringify({ id_token: "jwt", access_token: "at" }),
|
||||
);
|
||||
}
|
||||
return new Response("{}", { status: 404 });
|
||||
}),
|
||||
);
|
||||
h.ldap.users = {
|
||||
bob: {
|
||||
dn: "uid=bob,ou=people",
|
||||
password: "hunter2",
|
||||
attrs: { uid: "bob", cn: "Bob Builder", mail: "bob@example.com" },
|
||||
},
|
||||
};
|
||||
h.ldap.adminDns = [];
|
||||
});
|
||||
|
||||
describe("OIDC through the pipeline", () => {
|
||||
it("turns the callback into an identity with the old identifier and claims", async () => {
|
||||
const identity = await handleOidcCallback(
|
||||
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
|
||||
);
|
||||
expect(identity).toMatchObject({
|
||||
kind: "external",
|
||||
provider: "3",
|
||||
subject: "sub-1",
|
||||
legacyIdentifier: "sub-1",
|
||||
name: "Alice",
|
||||
isAdmin: true,
|
||||
oidcSub: "sub-1",
|
||||
oidcSid: "sid-1",
|
||||
ssoProviderId: 3,
|
||||
returnTo: "https://termix.example",
|
||||
});
|
||||
// The one-time state is consumed.
|
||||
expect(h.state.settings.has("oidc_state_state1")).toBe(false);
|
||||
});
|
||||
|
||||
it("signs in and redirects with a cookie, like the old callback", async () => {
|
||||
const req = fakeRequest({ query: { code: "c", state: "state1" } });
|
||||
const identity = await handleOidcCallback(req as never);
|
||||
const res = fakeResponse();
|
||||
await respondWithRedirectLogin(
|
||||
req as never,
|
||||
res as never,
|
||||
identity,
|
||||
{ methodId: "oidc", rememberMe: false },
|
||||
() => false,
|
||||
);
|
||||
expect(res.redirectedTo).toBe("https://termix.example/?success=true");
|
||||
expect(res.cleared).toContain("jwt");
|
||||
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
|
||||
const [user] = [...h.state.users.values()];
|
||||
expect(user).toMatchObject({ oidcIdentifier: "sub-1", isAdmin: true });
|
||||
expect(h.state.audits).toContainEqual(
|
||||
expect.objectContaining({ action: "login", userId: user.id }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a nonce mismatch without a session", async () => {
|
||||
h.idTokenClaims = { ...(h.idTokenClaims as object), nonce: "other" };
|
||||
await expect(
|
||||
handleOidcCallback(
|
||||
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
|
||||
),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it("namespaces GitHub users the way they were stored", async () => {
|
||||
h.providerType = "github";
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (url === "https://idp.example/token") {
|
||||
return new Response(JSON.stringify({ access_token: "at" }));
|
||||
}
|
||||
if (url === "https://api.github.com/user") {
|
||||
return new Response(JSON.stringify({ id: 42, login: "octo" }));
|
||||
}
|
||||
return new Response("[]");
|
||||
}),
|
||||
);
|
||||
const identity = await handleOidcCallback(
|
||||
fakeRequest({ query: { code: "c", state: "state1" } }) as never,
|
||||
);
|
||||
expect(identity).toMatchObject({
|
||||
provider: "3",
|
||||
subject: "42",
|
||||
legacyIdentifier: "github:3:42",
|
||||
name: "octo",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LDAP through the pipeline", () => {
|
||||
const providerConfig = {
|
||||
host: "ldap.example",
|
||||
bindDN: "cn=service",
|
||||
bindPassword: "svc",
|
||||
userSearchBase: "ou=people",
|
||||
userSearchFilter: "(uid={{username}})",
|
||||
groupSearchBase: "ou=groups",
|
||||
adminGroup: "admins",
|
||||
};
|
||||
|
||||
it("binds, finds the user and signs in with the old identifier", async () => {
|
||||
h.providerConfig = providerConfig;
|
||||
h.ldap.adminDns = ["uid=bob,ou=people"];
|
||||
const req = fakeRequest({
|
||||
body: { providerId: 4, username: "bob", password: "hunter2" },
|
||||
headers: { "x-electron-app": "true" },
|
||||
});
|
||||
const identity = await verifyLdapLogin({
|
||||
body: req.body as never,
|
||||
ip: "10.0.0.1",
|
||||
});
|
||||
expect(identity).toMatchObject({
|
||||
provider: "4",
|
||||
subject: "bob",
|
||||
legacyIdentifier: "ldap:4:bob",
|
||||
isAdmin: true,
|
||||
name: "Bob Builder",
|
||||
});
|
||||
|
||||
const res = fakeResponse();
|
||||
await respondWithLogin(req as never, res as never, identity, {
|
||||
methodId: "ldap",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
|
||||
// Native clients now get the token for LDAP too.
|
||||
expect(res.body).toMatchObject({
|
||||
success: true,
|
||||
token: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a wrong password", async () => {
|
||||
h.providerConfig = providerConfig;
|
||||
await expect(
|
||||
verifyLdapLogin({
|
||||
body: { providerId: 4, username: "bob", password: "wrong" },
|
||||
ip: "10.0.0.2",
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 401 });
|
||||
});
|
||||
|
||||
it("skips second factors for an LDAP user who enrolled when the external-login setting is off", async () => {
|
||||
h.providerConfig = providerConfig;
|
||||
h.state.users.set("u-bob", {
|
||||
id: "u-bob",
|
||||
username: "Bob Builder",
|
||||
passwordHash: "",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "ldap:4:bob",
|
||||
});
|
||||
h.state.factors.push({
|
||||
userId: "u-bob",
|
||||
pluginId: "fixture",
|
||||
factorId: "pin",
|
||||
});
|
||||
const identity = await verifyLdapLogin({
|
||||
body: { providerId: 4, username: "bob", password: "hunter2" },
|
||||
ip: "10.0.0.3",
|
||||
});
|
||||
const result = await runLogin(fakeRequest() as never, identity, {
|
||||
methodId: "ldap",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(result.kind).toBe("session");
|
||||
});
|
||||
|
||||
it("runs second factors for an LDAP user who enrolled when the external-login setting is on", async () => {
|
||||
h.providerConfig = providerConfig;
|
||||
h.state.settings.set("second_factor_after_external_login", "true");
|
||||
h.state.users.set("u-bob", {
|
||||
id: "u-bob",
|
||||
username: "Bob Builder",
|
||||
passwordHash: "",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "ldap:4:bob",
|
||||
});
|
||||
h.state.factors.push({
|
||||
userId: "u-bob",
|
||||
pluginId: "fixture",
|
||||
factorId: "pin",
|
||||
});
|
||||
const identity = await verifyLdapLogin({
|
||||
body: { providerId: 4, username: "bob", password: "hunter2" },
|
||||
ip: "10.0.0.3",
|
||||
});
|
||||
const { registerSecondFactor } = await import("../../auth/registry.js");
|
||||
const dispose = registerSecondFactor({
|
||||
id: "pin",
|
||||
pluginId: "fixture",
|
||||
labelKey: "pin",
|
||||
isEnrolled: async () => false,
|
||||
verify: async () => true,
|
||||
});
|
||||
try {
|
||||
const result = await runLogin(fakeRequest() as never, identity, {
|
||||
methodId: "ldap",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(result.kind).toBe("second-factor");
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
import {
|
||||
createAuthState,
|
||||
@@ -464,6 +464,19 @@ describe("external identities", () => {
|
||||
ssoProviderId: 3,
|
||||
};
|
||||
|
||||
// The sso plugin's method, which marks itself external.
|
||||
let disposeOidc: () => void = () => {};
|
||||
beforeEach(() => {
|
||||
disposeOidc = registerLoginMethod({
|
||||
id: "oidc",
|
||||
pluginId: "sso",
|
||||
labelKey: "loginWithSso",
|
||||
kind: "redirect",
|
||||
external: true,
|
||||
});
|
||||
});
|
||||
afterEach(() => disposeOidc());
|
||||
|
||||
it("provisions the first user as admin and links the identity", async () => {
|
||||
const result = await runLogin(fakeRequest() as never, external, {
|
||||
methodId: "oidc",
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import { SsoProviderRepository } from "../../../database/repositories/sso-provider-repository.js";
|
||||
|
||||
describe("SsoProviderRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (adapter) {
|
||||
await adapter.close();
|
||||
adapter = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRepository(
|
||||
onWrite?: () => void | Promise<void>,
|
||||
): Promise<SsoProviderRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
|
||||
return new SsoProviderRepository(context, onWrite);
|
||||
}
|
||||
|
||||
it("creates, lists, finds, updates, and deletes providers", async () => {
|
||||
const repo = await createRepository();
|
||||
|
||||
const disabled = await repo.create({
|
||||
name: "Disabled",
|
||||
type: "oidc",
|
||||
enabled: false,
|
||||
displayOrder: 1,
|
||||
config: "{}",
|
||||
});
|
||||
const enabled = await repo.create({
|
||||
name: "GitHub",
|
||||
type: "github",
|
||||
enabled: true,
|
||||
displayOrder: 0,
|
||||
config: '{"client_id":"id"}',
|
||||
});
|
||||
|
||||
expect((await repo.listEnabledPublic()).map((row) => row.id)).toEqual([
|
||||
enabled.id,
|
||||
]);
|
||||
expect((await repo.listAll()).map((row) => row.id)).toEqual([
|
||||
enabled.id,
|
||||
disabled.id,
|
||||
]);
|
||||
expect((await repo.findById(enabled.id))?.name).toBe("GitHub");
|
||||
expect((await repo.findFirstEnabledOidcLike())?.id).toBe(enabled.id);
|
||||
|
||||
const updated = await repo.update(enabled.id, {
|
||||
name: "GitHub SSO",
|
||||
config: '{"client_id":"updated"}',
|
||||
updatedAt: "2026-06-27T00:00:00.000Z",
|
||||
});
|
||||
expect(updated?.name).toBe("GitHub SSO");
|
||||
expect(updated?.config).toContain("updated");
|
||||
|
||||
expect(await repo.delete(enabled.id)).toBe(true);
|
||||
expect(await repo.findById(enabled.id)).toBeNull();
|
||||
expect(await repo.delete(enabled.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("counts users associated with a provider", async () => {
|
||||
const repo = await createRepository();
|
||||
const provider = await repo.create({
|
||||
name: "LDAP",
|
||||
type: "ldap",
|
||||
enabled: true,
|
||||
displayOrder: 0,
|
||||
config: "{}",
|
||||
});
|
||||
|
||||
await adapter!.exec(`
|
||||
INSERT INTO users (id, username, password_hash, sso_provider_id)
|
||||
VALUES ('user-1', 'u1', 'hash', ${provider.id}),
|
||||
('user-2', 'u2', 'hash', ${provider.id}),
|
||||
('user-3', 'u3', 'hash', NULL);
|
||||
`);
|
||||
|
||||
expect(await repo.countUsersByProviderId(provider.id)).toBe(2);
|
||||
});
|
||||
|
||||
it("runs the write hook after provider writes", async () => {
|
||||
let writeCount = 0;
|
||||
const repo = await createRepository(() => {
|
||||
writeCount += 1;
|
||||
});
|
||||
|
||||
const provider = await repo.create({
|
||||
name: "OIDC",
|
||||
type: "oidc",
|
||||
enabled: true,
|
||||
displayOrder: 0,
|
||||
config: "{}",
|
||||
});
|
||||
await repo.update(provider.id, { enabled: false });
|
||||
await repo.delete(provider.id);
|
||||
await repo.delete(provider.id);
|
||||
|
||||
expect(writeCount).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* The 2.8 login URLs identity providers and 2.8 clients still call. They
|
||||
* forward to whatever replaced them and, with the method's plugin gone, sign
|
||||
* nobody in.
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { authLogger: log, databaseLogger: log, logger: log };
|
||||
});
|
||||
vi.mock("../../../auth/core-auth.js", () => ({
|
||||
ensureCoreLoginProviders: () => {},
|
||||
}));
|
||||
vi.mock("../../../utils/trusted-proxy-auth.js", () => ({
|
||||
isTrustedProxyAuthEnabled: () => false,
|
||||
}));
|
||||
|
||||
const { registerAuthCompatRoutes } =
|
||||
await import("../../../database/routes/auth-compat-routes.js");
|
||||
const { registerLoginMethod, resetAuthRegistryForTests } =
|
||||
await import("../../../auth/registry.js");
|
||||
|
||||
let server: http.Server;
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
resetAuthRegistryForTests();
|
||||
const router = express.Router();
|
||||
router.use(express.json());
|
||||
registerAuthCompatRoutes(router);
|
||||
const app = express();
|
||||
app.use("/users", router);
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
function get(path: string) {
|
||||
return fetch(`${base}${path}`, { redirect: "manual" });
|
||||
}
|
||||
|
||||
function registerSso() {
|
||||
return registerLoginMethod({
|
||||
id: "oidc",
|
||||
pluginId: "sso",
|
||||
labelKey: "loginWithSso",
|
||||
kind: "redirect",
|
||||
external: true,
|
||||
describe: async () => [
|
||||
{ id: "3", label: "Keycloak", enabled: true, type: "oidc" },
|
||||
],
|
||||
start: async (_request, instanceId) => ({
|
||||
redirectUrl: `https://idp.example/auth?provider=${instanceId}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("the old identity provider URLs", () => {
|
||||
it("forwards the callback to the sso plugin, query intact", async () => {
|
||||
const response = await get("/users/oidc/callback?code=abc&state=xyz");
|
||||
expect(response.status).toBe(308);
|
||||
expect(response.headers.get("location")).toBe(
|
||||
"/plugin-api/sso/callback?code=abc&state=xyz",
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards a form-posted callback with a 308 so the body is resent", async () => {
|
||||
const response = await fetch(`${base}/users/oidc/callback`, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(response.status).toBe(308);
|
||||
});
|
||||
|
||||
it("forwards back-channel logout to the sso plugin", async () => {
|
||||
const response = await fetch(`${base}/users/oidc/backchannel-logout`, {
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
});
|
||||
expect(response.status).toBe(308);
|
||||
expect(response.headers.get("location")).toMatch(
|
||||
/\/plugin-api\/sso\/backchannel-logout$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards the public config to the sso plugin", async () => {
|
||||
const response = await get("/users/oidc-config");
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toMatch(
|
||||
/\/plugin-api\/sso\/config$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the old client routes", () => {
|
||||
it("starts an SSO login through the oidc method", async () => {
|
||||
registerSso();
|
||||
const response = await get("/users/oidc/authorize?providerId=3");
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({
|
||||
auth_url: "https://idp.example/auth?provider=3",
|
||||
});
|
||||
});
|
||||
|
||||
it("lists providers in the 2.8 shape", async () => {
|
||||
registerSso();
|
||||
const response = await get("/users/sso-providers");
|
||||
expect(await response.json()).toEqual([
|
||||
{ id: 3, name: "Keycloak", type: "oidc", displayOrder: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("answers 404 once the plugins are gone", async () => {
|
||||
const dispose = registerSso();
|
||||
dispose();
|
||||
expect((await get("/users/oidc/authorize?providerId=3")).status).toBe(404);
|
||||
expect(await (await get("/users/sso-providers")).json()).toEqual([]);
|
||||
const ldap = await fetch(`${base}/users/ldap/login`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ providerId: 4, username: "bob", password: "x" }),
|
||||
});
|
||||
expect(ldap.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({
|
||||
AuthManager: {
|
||||
getInstance: () => ({ createAdminMiddleware: vi.fn() }),
|
||||
},
|
||||
}));
|
||||
|
||||
const { isValidOidcIssuer } =
|
||||
await import("../../../database/routes/sso-provider-routes.js");
|
||||
|
||||
describe("isValidOidcIssuer", () => {
|
||||
it("rejects userinfo endpoints used as issuer URLs", () => {
|
||||
expect(
|
||||
isValidOidcIssuer("https://auth.example/application/o/userinfo/"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an Authentik application issuer", () => {
|
||||
expect(isValidOidcIssuer("https://auth.example/application/o/termix")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Linking an external (SSO or LDAP) account to a password account and back.
|
||||
* The linked account keeps its sign-in identities, so it signs in through the
|
||||
* same provider afterwards; unlinking drops them.
|
||||
*/
|
||||
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express from "express";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
isOidc: boolean;
|
||||
passwordHash: string;
|
||||
oidcIdentifier?: string | null;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
users: new Map<string, User>(),
|
||||
identities: [] as Array<{
|
||||
userId: string;
|
||||
providerId: string;
|
||||
subject: string;
|
||||
}>,
|
||||
deleted: [] as string[],
|
||||
revoked: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../../database/repositories/factory.js", () => ({
|
||||
createCurrentUserRepository: () => ({
|
||||
findById: async (id: string) => h.users.get(id) ?? null,
|
||||
findByUsername: async (username: string) =>
|
||||
[...h.users.values()].find((user) => user.username === username) ?? null,
|
||||
update: async (id: string, changes: Partial<User>) => {
|
||||
Object.assign(h.users.get(id)!, changes);
|
||||
},
|
||||
}),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
moveIdentities: async (from: string, to: string) => {
|
||||
for (const row of h.identities) if (row.userId === from) row.userId = to;
|
||||
},
|
||||
unlinkIdentitiesForUser: async (userId: string) => {
|
||||
h.identities = h.identities.filter((row) => row.userId !== userId);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../database/routes/delete-user-data.js", () => ({
|
||||
deleteUserAndRelatedData: async (userId: string) => {
|
||||
h.deleted.push(userId);
|
||||
h.users.delete(userId);
|
||||
},
|
||||
}));
|
||||
vi.mock("../../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
|
||||
}));
|
||||
vi.mock("../../../utils/auth-manager.js", () => ({ AuthManager: {} }));
|
||||
vi.mock("../../../utils/logger.js", () => {
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
};
|
||||
return { authLogger: log };
|
||||
});
|
||||
|
||||
const { registerUserOidcAccountRoutes } =
|
||||
await import("../../../database/routes/user-oidc-account-routes.js");
|
||||
|
||||
let server: http.Server;
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
h.users = new Map([
|
||||
[
|
||||
"admin",
|
||||
{
|
||||
id: "admin",
|
||||
username: "admin",
|
||||
isAdmin: true,
|
||||
isOidc: false,
|
||||
passwordHash: "x",
|
||||
},
|
||||
],
|
||||
[
|
||||
"sso",
|
||||
{
|
||||
id: "sso",
|
||||
username: "alice-sso",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
passwordHash: "",
|
||||
oidcIdentifier: "ldap:4:alice",
|
||||
},
|
||||
],
|
||||
[
|
||||
"local",
|
||||
{
|
||||
id: "local",
|
||||
username: "alice",
|
||||
isAdmin: false,
|
||||
isOidc: false,
|
||||
passwordHash: "hash",
|
||||
},
|
||||
],
|
||||
]);
|
||||
h.identities = [{ userId: "sso", providerId: "ldap:4", subject: "alice" }];
|
||||
h.deleted = [];
|
||||
h.revoked = [];
|
||||
|
||||
const router = express.Router();
|
||||
router.use(express.json());
|
||||
registerUserOidcAccountRoutes(router, {
|
||||
authenticateJWT: (req, _res, next) => {
|
||||
(req as unknown as { userId: string }).userId =
|
||||
req.header("x-user") ?? "";
|
||||
next();
|
||||
},
|
||||
authManager: {
|
||||
revokeAllUserSessions: async (userId: string) => {
|
||||
h.revoked.push(userId);
|
||||
},
|
||||
logoutUser: () => {},
|
||||
} as never,
|
||||
});
|
||||
const app = express();
|
||||
app.use("/users", router);
|
||||
server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
function post(path: string, body: unknown, user = "admin") {
|
||||
return fetch(`${base}/users${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", "x-user": user },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("linking an external account to a password account", () => {
|
||||
it("moves the identities and the old identifier, then removes the external account", async () => {
|
||||
const response = await post("/link-oidc-to-password", {
|
||||
oidcUserId: "sso",
|
||||
targetUsername: "alice",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(h.users.get("local")).toMatchObject({
|
||||
isOidc: true,
|
||||
oidcIdentifier: "ldap:4:alice",
|
||||
});
|
||||
expect(h.identities).toEqual([
|
||||
{ userId: "local", providerId: "ldap:4", subject: "alice" },
|
||||
]);
|
||||
expect(h.revoked).toEqual(["sso"]);
|
||||
expect(h.deleted).toEqual(["sso"]);
|
||||
});
|
||||
|
||||
it("is for admins only", async () => {
|
||||
const response = await post(
|
||||
"/link-oidc-to-password",
|
||||
{ oidcUserId: "sso", targetUsername: "alice" },
|
||||
"local",
|
||||
);
|
||||
expect(response.status).toBe(403);
|
||||
expect(h.identities[0].userId).toBe("sso");
|
||||
});
|
||||
|
||||
it("refuses a target that is not a password account", async () => {
|
||||
const response = await post("/link-oidc-to-password", {
|
||||
oidcUserId: "sso",
|
||||
targetUsername: "alice-sso",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unlinking", () => {
|
||||
it("drops the identities so the provider no longer signs in as the user", async () => {
|
||||
await post("/link-oidc-to-password", {
|
||||
oidcUserId: "sso",
|
||||
targetUsername: "alice",
|
||||
});
|
||||
const response = await post("/unlink-oidc-from-password", {
|
||||
userId: "local",
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
expect(h.users.get("local")).toMatchObject({
|
||||
isOidc: false,
|
||||
oidcIdentifier: null,
|
||||
});
|
||||
expect(h.identities).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses to leave an account with no way in", async () => {
|
||||
const response = await post("/unlink-oidc-from-password", {
|
||||
userId: "sso",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
expect(h.identities).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,609 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// user-oidc-utils imports the logger; stub it so importing stays side-effect-free.
|
||||
vi.mock("../../../utils/logger.js", () => ({
|
||||
authLogger: {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const {
|
||||
isOIDCUserAllowed,
|
||||
getOIDCConfigFromEnv,
|
||||
extractOidcGroups,
|
||||
extractOidcGroupsFromSources,
|
||||
validateLogoutTokenClaims,
|
||||
parseOidcRoleMap,
|
||||
resolveOidcMappedRoles,
|
||||
verifyOIDCToken,
|
||||
describeFetchFailure,
|
||||
isOIDCEnvOverrideEnabled,
|
||||
} = await import("../../../database/routes/user-oidc-utils.js");
|
||||
|
||||
const BACKCHANNEL_LOGOUT_EVENT =
|
||||
"http://schemas.openid.net/event/backchannel-logout";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("describeFetchFailure", () => {
|
||||
it("unwraps the undici cause, which carries the reason that matters", () => {
|
||||
// Every transport failure surfaces as this same outer message.
|
||||
const error = new TypeError("fetch failed", {
|
||||
cause: Object.assign(new Error("getaddrinfo ENOTFOUND idp.example"), {
|
||||
code: "ENOTFOUND",
|
||||
}),
|
||||
});
|
||||
expect(describeFetchFailure(error)).toBe(
|
||||
"fetch failed: getaddrinfo ENOTFOUND idp.example (ENOTFOUND)",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the outer message when there is no cause", () => {
|
||||
expect(describeFetchFailure(new Error("boom"))).toBe("boom");
|
||||
});
|
||||
|
||||
it("handles a non-Error throw", () => {
|
||||
expect(describeFetchFailure("nope")).toBe("nope");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyOIDCToken JWKS diagnostics", () => {
|
||||
const issuer = "https://login.microsoftonline.com/example/v2.0";
|
||||
const token = "header.payload.signature";
|
||||
|
||||
it("reports every attempted URL and why it failed", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response("not found", { status: 404 }),
|
||||
);
|
||||
|
||||
const error = await verifyOIDCToken(token, issuer, "client").catch(
|
||||
(e) => e as Error,
|
||||
);
|
||||
expect(error.message).toMatch(/^Failed to fetch JWKS from any URL/);
|
||||
expect(error.message).toContain(
|
||||
`${issuer}/.well-known/openid-configuration: HTTP 404`,
|
||||
);
|
||||
expect(error.message).toContain(
|
||||
`${issuer}/.well-known/jwks.json: HTTP 404`,
|
||||
);
|
||||
expect(error.message).toContain(`${issuer}/jwks/: HTTP 404`);
|
||||
});
|
||||
|
||||
it("reports a transport failure with its underlying cause", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
||||
new TypeError("fetch failed", {
|
||||
cause: Object.assign(new Error("self-signed certificate"), {
|
||||
code: "SELF_SIGNED_CERT_IN_CHAIN",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const error = await verifyOIDCToken(token, issuer, "client").catch(
|
||||
(e) => e as Error,
|
||||
);
|
||||
expect(error.message).toContain(
|
||||
"self-signed certificate (SELF_SIGNED_CERT_IN_CHAIN)",
|
||||
);
|
||||
});
|
||||
|
||||
it("says so when discovery succeeds but advertises no jwks_uri", async () => {
|
||||
vi.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ issuer }), { status: 200 }),
|
||||
)
|
||||
.mockResolvedValue(new Response("not found", { status: 404 }));
|
||||
|
||||
const error = await verifyOIDCToken(token, issuer, "client").catch(
|
||||
(e) => e as Error,
|
||||
);
|
||||
expect(error.message).toContain("no jwks_uri in the discovery document");
|
||||
});
|
||||
|
||||
it("says so when a JWKS response carries no keys array", async () => {
|
||||
vi.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), {
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
|
||||
const error = await verifyOIDCToken(token, issuer, "client").catch(
|
||||
(e) => e as Error,
|
||||
);
|
||||
expect(error.message).toContain(
|
||||
'https://idp.example/keys: response contains no "keys" array',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyOIDCToken", () => {
|
||||
it("accepts a discovery document URL as the configured issuer", async () => {
|
||||
const { exportJWK, generateKeyPair, SignJWT } = await import("jose");
|
||||
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
||||
const jwk = await exportJWK(publicKey);
|
||||
jwk.kid = "google-key";
|
||||
|
||||
const issuer = "https://accounts.google.com";
|
||||
const clientId = "termix-client";
|
||||
const token = await new SignJWT({ sub: "user-1" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: jwk.kid })
|
||||
.setIssuer(issuer)
|
||||
.setAudience(clientId)
|
||||
.setExpirationTime("5m")
|
||||
.sign(privateKey);
|
||||
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ jwks_uri: `${issuer}/keys` }), {
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }),
|
||||
);
|
||||
|
||||
const payload = await verifyOIDCToken(
|
||||
token,
|
||||
`${issuer}/.well-known/openid-configuration`,
|
||||
clientId,
|
||||
);
|
||||
|
||||
expect(payload.sub).toBe("user-1");
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
`${issuer}/.well-known/openid-configuration`,
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the protected-header algorithm when the provider JWK omits alg", async () => {
|
||||
const { exportJWK, generateKeyPair, SignJWT } = await import("jose");
|
||||
const { publicKey, privateKey } = await generateKeyPair("RS256");
|
||||
const jwk = await exportJWK(publicKey);
|
||||
jwk.kid = "entra-key";
|
||||
|
||||
const issuer = "https://login.microsoftonline.com/example/v2.0";
|
||||
const clientId = "termix-client";
|
||||
const token = await new SignJWT({ sub: "user-1" })
|
||||
.setProtectedHeader({ alg: "RS256", kid: jwk.kid })
|
||||
.setIssuer(issuer)
|
||||
.setAudience(clientId)
|
||||
.setExpirationTime("5m")
|
||||
.sign(privateKey);
|
||||
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ jwks_uri: "https://idp.example/keys" }), {
|
||||
status: 200,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ keys: [jwk] }), { status: 200 }),
|
||||
);
|
||||
|
||||
const payload = await verifyOIDCToken(token, issuer, clientId);
|
||||
|
||||
expect(payload.sub).toBe("user-1");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isOIDCUserAllowed", () => {
|
||||
it("allows everyone when the allow-list is empty", () => {
|
||||
expect(isOIDCUserAllowed("", "alice", "alice@x.com")).toBe(true);
|
||||
expect(isOIDCUserAllowed(" ", "alice")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows everyone with the '*' wildcard", () => {
|
||||
expect(isOIDCUserAllowed("*", "anyone", "anyone@x.com")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches an exact identifier (case-insensitive)", () => {
|
||||
expect(isOIDCUserAllowed("alice,bob", "alice")).toBe(true);
|
||||
expect(isOIDCUserAllowed("Alice", "alice")).toBe(true);
|
||||
expect(isOIDCUserAllowed("alice", "ALICE")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches against the email as well as the identifier", () => {
|
||||
expect(isOIDCUserAllowed("alice@x.com", "sub-123", "alice@x.com")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("matches an @domain suffix pattern", () => {
|
||||
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@company.com")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@COMPANY.COM")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("denies users not on the list", () => {
|
||||
expect(isOIDCUserAllowed("alice,bob", "charlie", "charlie@x.com")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isOIDCUserAllowed("@company.com", "sub-1", "bob@other.com")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores blank entries and surrounding whitespace in the list", () => {
|
||||
expect(isOIDCUserAllowed(" alice , , bob ", "bob")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not match the email against an identifier-only pattern when email differs", () => {
|
||||
expect(isOIDCUserAllowed("alice", "sub-123", "alice@x.com")).toBe(false);
|
||||
});
|
||||
|
||||
it("matches *@domain.com wildcard pattern against emails", () => {
|
||||
expect(
|
||||
isOIDCUserAllowed("*@company.com", "sub-1", "john@company.com"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isOIDCUserAllowed("*@company.com", "sub-1", "jane@COMPANY.COM"),
|
||||
).toBe(true);
|
||||
expect(isOIDCUserAllowed("*@company.com", "sub-1", "user@other.com")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("matches glob patterns with multiple wildcards", () => {
|
||||
expect(isOIDCUserAllowed("admin*", "admin_user")).toBe(true);
|
||||
expect(isOIDCUserAllowed("admin*", "user_admin")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOIDCConfigFromEnv", () => {
|
||||
const REQUIRED = [
|
||||
"OIDC_CLIENT_ID",
|
||||
"OIDC_CLIENT_SECRET",
|
||||
"OIDC_ISSUER_URL",
|
||||
"OIDC_AUTHORIZATION_URL",
|
||||
"OIDC_TOKEN_URL",
|
||||
];
|
||||
const OPTIONAL = [
|
||||
"OIDC_USERINFO_URL",
|
||||
"OIDC_IDENTIFIER_PATH",
|
||||
"OIDC_NAME_PATH",
|
||||
"OIDC_SCOPES",
|
||||
"OIDC_ALLOWED_USERS",
|
||||
"OIDC_ADMIN_GROUP",
|
||||
"OIDC_ENV_OVERRIDE",
|
||||
];
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of [...REQUIRED, ...OPTIONAL]) {
|
||||
saved[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of [...REQUIRED, ...OPTIONAL]) {
|
||||
if (saved[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = saved[key];
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null when any required variable is missing", () => {
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
// issuer/authorization/token urls intentionally missing
|
||||
expect(getOIDCConfigFromEnv()).toBeNull();
|
||||
});
|
||||
|
||||
it("builds a config with defaults when all required vars are present", () => {
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example";
|
||||
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
|
||||
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
|
||||
|
||||
const config = getOIDCConfigFromEnv();
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.client_id).toBe("id");
|
||||
expect(config?.identifier_path).toBe("sub");
|
||||
expect(config?.name_path).toBe("name");
|
||||
expect(config?.scopes).toBe("openid email profile");
|
||||
expect(config?.userinfo_url).toBe("");
|
||||
});
|
||||
|
||||
it("honors overrides for optional vars", () => {
|
||||
process.env.OIDC_CLIENT_ID = "id";
|
||||
process.env.OIDC_CLIENT_SECRET = "secret";
|
||||
process.env.OIDC_ISSUER_URL = "https://idp.example";
|
||||
process.env.OIDC_AUTHORIZATION_URL = "https://idp.example/auth";
|
||||
process.env.OIDC_TOKEN_URL = "https://idp.example/token";
|
||||
process.env.OIDC_IDENTIFIER_PATH = "email";
|
||||
process.env.OIDC_SCOPES = "openid";
|
||||
|
||||
const config = getOIDCConfigFromEnv();
|
||||
expect(config?.identifier_path).toBe("email");
|
||||
expect(config?.scopes).toBe("openid");
|
||||
});
|
||||
|
||||
it("only enables database recovery override when explicitly requested", () => {
|
||||
expect(isOIDCEnvOverrideEnabled()).toBe(false);
|
||||
process.env.OIDC_ENV_OVERRIDE = "true";
|
||||
expect(isOIDCEnvOverrideEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractOidcGroups", () => {
|
||||
it("reads the standard groups claim as an array", () => {
|
||||
expect(extractOidcGroups({ groups: ["admin", "user"] })).toEqual([
|
||||
"admin",
|
||||
"user",
|
||||
]);
|
||||
});
|
||||
|
||||
it("splits a comma-separated string claim", () => {
|
||||
expect(extractOidcGroups({ roles: "admin, user" })).toEqual([
|
||||
"admin",
|
||||
"user",
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back through groups, roles, then group", () => {
|
||||
expect(extractOidcGroups({ group: "ops" })).toEqual(["ops"]);
|
||||
});
|
||||
|
||||
it("reads a custom claim path when provided", () => {
|
||||
const userInfo = {
|
||||
"zitadel:grants:groups:123": ["user", "admin"],
|
||||
groups: ["ignored"],
|
||||
};
|
||||
expect(extractOidcGroups(userInfo, "zitadel:grants:groups:123")).toEqual([
|
||||
"user",
|
||||
"admin",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses object keys as group names (Zitadel roles object)", () => {
|
||||
const userInfo = {
|
||||
"urn:zitadel:iam:org:project:roles": { admin: {}, user: {} },
|
||||
};
|
||||
expect(
|
||||
extractOidcGroups(userInfo, "urn:zitadel:iam:org:project:roles"),
|
||||
).toEqual(["admin", "user"]);
|
||||
});
|
||||
|
||||
it("falls back to defaults when the custom claim is absent", () => {
|
||||
expect(extractOidcGroups({ groups: ["admin"] }, "missing")).toEqual([
|
||||
"admin",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty array when no groups are present", () => {
|
||||
expect(extractOidcGroups({})).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractOidcGroupsFromSources", () => {
|
||||
it("preserves ID token groups when userinfo omits them", () => {
|
||||
expect(
|
||||
extractOidcGroupsFromSources([
|
||||
{ groups: ["admins", "users"] },
|
||||
{ sub: "user-1", name: "Example User" },
|
||||
]),
|
||||
).toEqual(["admins", "users"]);
|
||||
});
|
||||
|
||||
it("combines and deduplicates groups from both verified sources", () => {
|
||||
expect(
|
||||
extractOidcGroupsFromSources([
|
||||
{ roles: ["users", "operators"] },
|
||||
{ roles: ["operators", "admins"] },
|
||||
]),
|
||||
).toEqual(["users", "operators", "admins"]);
|
||||
});
|
||||
|
||||
it("supports a configured group claim across sources", () => {
|
||||
expect(
|
||||
extractOidcGroupsFromSources(
|
||||
[{ custom_groups: ["admins"] }, { custom_groups: ["users"] }],
|
||||
"custom_groups",
|
||||
),
|
||||
).toEqual(["admins", "users"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateLogoutTokenClaims", () => {
|
||||
const validClaims = {
|
||||
sub: "subject-1",
|
||||
sid: "session-1",
|
||||
iat: 1_783_641_600,
|
||||
jti: "logout-1",
|
||||
events: { [BACKCHANNEL_LOGOUT_EVENT]: {} },
|
||||
};
|
||||
|
||||
it("accepts a spec-compliant back-channel logout payload", () => {
|
||||
expect(validateLogoutTokenClaims(validClaims)).toEqual({
|
||||
sub: "subject-1",
|
||||
sid: "session-1",
|
||||
jti: "logout-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires the logout event to contain an object", () => {
|
||||
expect(() =>
|
||||
validateLogoutTokenClaims({
|
||||
...validClaims,
|
||||
events: { [BACKCHANNEL_LOGOUT_EVENT]: true },
|
||||
}),
|
||||
).toThrow("missing back-channel logout event");
|
||||
});
|
||||
|
||||
it("requires iat and jti claims", () => {
|
||||
expect(() =>
|
||||
validateLogoutTokenClaims({ ...validClaims, iat: undefined }),
|
||||
).toThrow("missing iat claim");
|
||||
expect(() =>
|
||||
validateLogoutTokenClaims({ ...validClaims, jti: "" }),
|
||||
).toThrow("missing jti claim");
|
||||
});
|
||||
|
||||
it("rejects nonce and requires sub or sid", () => {
|
||||
expect(() =>
|
||||
validateLogoutTokenClaims({ ...validClaims, nonce: "forbidden" }),
|
||||
).toThrow("must not contain a nonce");
|
||||
expect(() =>
|
||||
validateLogoutTokenClaims({ ...validClaims, sub: null, sid: null }),
|
||||
).toThrow("must contain sub and/or sid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseOidcRoleMap", () => {
|
||||
it("returns an empty map for blank input", () => {
|
||||
expect(parseOidcRoleMap(undefined).size).toBe(0);
|
||||
expect(parseOidcRoleMap(null).size).toBe(0);
|
||||
expect(parseOidcRoleMap(" ").size).toBe(0);
|
||||
});
|
||||
|
||||
it("parses comma-separated group:role pairs", () => {
|
||||
const map = parseOidcRoleMap(
|
||||
"devops-interns:devops-intern,devops-seniors:devops-senior",
|
||||
);
|
||||
expect(map.get("devops-interns")).toBe("devops-intern");
|
||||
expect(map.get("devops-seniors")).toBe("devops-senior");
|
||||
expect(map.size).toBe(2);
|
||||
});
|
||||
|
||||
it("parses newline-separated pairs and trims whitespace", () => {
|
||||
const map = parseOidcRoleMap(" a : role-a \n b:role-b \n");
|
||||
expect(map.get("a")).toBe("role-a");
|
||||
expect(map.get("b")).toBe("role-b");
|
||||
});
|
||||
|
||||
it("normalizes leading slashes and case in group names", () => {
|
||||
const map = parseOidcRoleMap("/DevOps-Interns:devops-intern");
|
||||
expect(map.get("devops-interns")).toBe("devops-intern");
|
||||
});
|
||||
|
||||
it("skips malformed entries instead of throwing", () => {
|
||||
const map = parseOidcRoleMap("no-colon,:missing-group,missing-role:,ok:r");
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.get("ok")).toBe("r");
|
||||
});
|
||||
|
||||
it("splits on the last colon so group names may contain colons", () => {
|
||||
const map = parseOidcRoleMap("ns:team:role-x");
|
||||
expect(map.get("ns:team")).toBe("role-x");
|
||||
});
|
||||
|
||||
it("preserves role-name case verbatim", () => {
|
||||
// Role names must match roles.name exactly, so they are not lowercased.
|
||||
expect(parseOidcRoleMap("g:DevOps_Senior").get("g")).toBe("DevOps_Senior");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOidcMappedRoles", () => {
|
||||
const roleMap = parseOidcRoleMap(
|
||||
"devops-interns:devops-intern,devops-seniors:devops-senior",
|
||||
);
|
||||
|
||||
it("reports every mapped role as managed regardless of membership", () => {
|
||||
const { managed } = resolveOidcMappedRoles([], roleMap);
|
||||
expect([...managed].sort()).toEqual(["devops-intern", "devops-senior"]);
|
||||
});
|
||||
|
||||
it("desires only the roles whose groups the user is in", () => {
|
||||
const { desired } = resolveOidcMappedRoles(["devops-interns"], roleMap);
|
||||
expect([...desired]).toEqual(["devops-intern"]);
|
||||
});
|
||||
|
||||
it("matches full group paths emitted by Keycloak", () => {
|
||||
const { desired } = resolveOidcMappedRoles(["/devops-seniors"], roleMap);
|
||||
expect([...desired]).toEqual(["devops-senior"]);
|
||||
});
|
||||
|
||||
it("ignores groups that are not mapped", () => {
|
||||
const { desired } = resolveOidcMappedRoles(
|
||||
["finance", "devops-interns"],
|
||||
roleMap,
|
||||
);
|
||||
expect([...desired]).toEqual(["devops-intern"]);
|
||||
});
|
||||
|
||||
it("supports a user in multiple mapped groups", () => {
|
||||
const { desired } = resolveOidcMappedRoles(
|
||||
["devops-interns", "devops-seniors"],
|
||||
roleMap,
|
||||
);
|
||||
expect([...desired].sort()).toEqual(["devops-intern", "devops-senior"]);
|
||||
});
|
||||
|
||||
it("desires nothing when the map is empty", () => {
|
||||
const { desired, managed } = resolveOidcMappedRoles(
|
||||
["devops-interns"],
|
||||
new Map(),
|
||||
);
|
||||
expect(desired.size).toBe(0);
|
||||
expect(managed.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// Imported as a namespace rather than destructured into the shared block at the
|
||||
// top of the file, so this suite stays independent of what that block binds.
|
||||
const oidcUtils = await import("../../../database/routes/user-oidc-utils.js");
|
||||
|
||||
describe("verifyOIDCToken token shape", () => {
|
||||
const issuer = "https://idp.example.com/application/o/termix";
|
||||
|
||||
// The shape check runs before any network call, so no fetch stub is needed.
|
||||
const fetchSpy = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
fetchSpy.mockReset();
|
||||
});
|
||||
|
||||
it("reports an encrypted (JWE) token as a format error", async () => {
|
||||
const jwe = ["header", "key", "iv", "ciphertext", "tag"].join(".");
|
||||
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken(jwe, issuer, "client"),
|
||||
).rejects.toThrow(oidcUtils.OIDCTokenFormatError);
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken(jwe, issuer, "client"),
|
||||
).rejects.toThrow(/JWE \(encrypted\)/);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports any other non-JWS segment count as a format error", async () => {
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken("header.payload", issuer, "client"),
|
||||
).rejects.toThrow(/expected 3 segments, got 2/);
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken("opaque", issuer, "client"),
|
||||
).rejects.toThrow(/expected 3 segments, got 1/);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a three-segment token through to key resolution", async () => {
|
||||
fetchSpy.mockResolvedValue({ ok: false });
|
||||
|
||||
// Reaches JWKS fetching, so it fails on the key lookup rather than the shape.
|
||||
await expect(
|
||||
oidcUtils.verifyOIDCToken("header.payload.signature", issuer, "client"),
|
||||
).rejects.not.toThrow(oidcUtils.OIDCTokenFormatError);
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -110,8 +110,10 @@ describe("expandOidcUsername", () => {
|
||||
ssoProviderId: 1,
|
||||
}),
|
||||
}),
|
||||
createCurrentSsoProviderRepository: () => ({
|
||||
findById: async () => ({ type: "ldap" }),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listIdentitiesForUser: async () => [
|
||||
{ providerId: "ldap:1", subject: "jdoe" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -128,8 +130,10 @@ describe("expandOidcUsername", () => {
|
||||
ssoProviderId: 1,
|
||||
}),
|
||||
}),
|
||||
createCurrentSsoProviderRepository: () => ({
|
||||
findById: async () => ({ type: "oidc" }),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listIdentitiesForUser: async () => [
|
||||
{ providerId: "1", subject: "ldap:1:admin" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -148,8 +152,10 @@ describe("expandOidcUsername", () => {
|
||||
ssoProviderId: 5,
|
||||
}),
|
||||
}),
|
||||
createCurrentSsoProviderRepository: () => ({
|
||||
findById: async () => ({ type: "ldap" }),
|
||||
createCurrentUserAuthRepository: () => ({
|
||||
listIdentitiesForUser: async () => [
|
||||
{ providerId: "ldap:5", subject: "admin" },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
/**
|
||||
* A plugin's login method through core: what its identity may carry, how its
|
||||
* own callback route hands off to core, and that a disabled plugin leaves no
|
||||
* way in, including through the 2.8 wrapper routes.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import {
|
||||
createAuthState,
|
||||
fakeAuthManager,
|
||||
fakeRequest,
|
||||
fakeResponse,
|
||||
type AuthState,
|
||||
} from "../auth/auth-test-helpers.js";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
state: null as unknown as AuthState,
|
||||
manager: null as unknown as ReturnType<
|
||||
typeof import("../auth/auth-test-helpers.js").fakeAuthManager
|
||||
>,
|
||||
granted: new Set<string>(),
|
||||
trustedProxy: false,
|
||||
audits: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../database/repositories/factory.js", async () => {
|
||||
const helpers = await import("../auth/auth-test-helpers.js");
|
||||
const names = Object.keys(helpers.fakeFactory(helpers.createAuthState()));
|
||||
return Object.fromEntries(
|
||||
names.map((name) => [
|
||||
name,
|
||||
(...args: unknown[]) =>
|
||||
(
|
||||
helpers.fakeFactory(h.state) as Record<
|
||||
string,
|
||||
(...a: unknown[]) => unknown
|
||||
>
|
||||
)[name](...args),
|
||||
]),
|
||||
);
|
||||
});
|
||||
vi.mock("../../utils/auth-manager.js", () => ({
|
||||
AuthManager: { getInstance: () => h.manager },
|
||||
}));
|
||||
vi.mock("../../utils/audit-logger.js", () => ({
|
||||
logAudit: async (entry: Record<string, unknown>) => {
|
||||
h.state.audits.push(entry);
|
||||
},
|
||||
getRequestMeta: () => ({ ipAddress: "10.0.0.1", userAgent: "test" }),
|
||||
}));
|
||||
vi.mock("../../utils/trusted-proxy-auth.js", () => ({
|
||||
isTrustedProxyAuthEnabled: () => h.trustedProxy,
|
||||
}));
|
||||
vi.mock("../../plugins/permissions.js", async () => {
|
||||
const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend");
|
||||
return {
|
||||
assertCapability: async (
|
||||
pluginId: string,
|
||||
capability: string,
|
||||
declared: readonly string[],
|
||||
) => {
|
||||
if (!declared.includes(capability) || !h.granted.has(capability)) {
|
||||
throw new PluginCapabilityError(pluginId, capability);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("../../hosts/internal-events.js", () => ({
|
||||
emitInternalEvent: vi.fn(),
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
return {
|
||||
authLogger: log,
|
||||
databaseLogger: log,
|
||||
sshLogger: log,
|
||||
pluginLogger: log,
|
||||
logger: log,
|
||||
};
|
||||
});
|
||||
vi.mock("../../utils/database-save-trigger.js", () => ({
|
||||
DatabaseSaveTrigger: { forceSave: vi.fn(async () => {}) },
|
||||
}));
|
||||
vi.mock("../../utils/permission-manager.js", () => ({
|
||||
PermissionManager: {
|
||||
getInstance: () => ({ invalidateUserPermissionCache: vi.fn() }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-host-secrets-manager.js", () => ({
|
||||
SharedHostSecretsManager: {
|
||||
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
|
||||
},
|
||||
}));
|
||||
vi.mock("../../utils/shared-credential-secrets-manager.js", () => ({
|
||||
SharedCredentialSecretsManager: {
|
||||
getInstance: () => ({ snapshotForUserRoles: async () => {} }),
|
||||
},
|
||||
}));
|
||||
|
||||
const { createPluginAuth } = await import("../../plugins/ctx-ssh-auth.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const { loginRateLimiter } = await import("../../utils/login-rate-limiter.js");
|
||||
const {
|
||||
listPublicLoginMethods,
|
||||
listLegacySsoProviders,
|
||||
startRedirectLogin,
|
||||
verifyFormLogin,
|
||||
} = await import("../../database/routes/auth-routes.js");
|
||||
const { runLogin } = await import("../../auth/login-pipeline.js");
|
||||
const { resetAuthRegistryForTests, getLoginMethod } =
|
||||
await import("../../auth/registry.js");
|
||||
const { resetCoreLoginProvidersForTests } =
|
||||
await import("../../auth/core-auth.js");
|
||||
|
||||
const manifest = {
|
||||
id: "corp",
|
||||
capabilities: ["auth:provide"],
|
||||
contributes: { auth: { loginMethods: ["corp-sso", "corp-dir"] } },
|
||||
} as unknown as PluginManifest;
|
||||
|
||||
function setup() {
|
||||
const bag = new DisposableBag();
|
||||
const auth = createPluginAuth({
|
||||
manifest,
|
||||
bag,
|
||||
audit: async (action) => {
|
||||
h.audits.push(action);
|
||||
},
|
||||
});
|
||||
auth.registerLoginMethod({
|
||||
id: "corp-sso",
|
||||
labelKey: "signIn",
|
||||
kind: "redirect",
|
||||
external: true,
|
||||
describe: async () => [
|
||||
{ id: "3", label: "Corp", enabled: true, type: "oidc" },
|
||||
],
|
||||
start: async () => ({ redirectUrl: "https://idp.example/auth" }),
|
||||
callback: async () => ({
|
||||
kind: "external",
|
||||
provider: "3",
|
||||
subject: "sub-1",
|
||||
name: "Alice",
|
||||
email: "alice@example.com",
|
||||
isAdmin: true,
|
||||
roles: { desired: ["ops"], managed: ["ops", "dev"] },
|
||||
logoutClaims: { providerId: 3, sub: "sub-1", sid: "sid-1" },
|
||||
legacy: { identifier: "sub-1", providerRowId: 3 },
|
||||
returnTo: "https://termix.example",
|
||||
}),
|
||||
});
|
||||
auth.registerLoginMethod({
|
||||
id: "corp-dir",
|
||||
labelKey: "signIn",
|
||||
kind: "form",
|
||||
external: true,
|
||||
describe: async () => [{ id: "4", label: "Directory", enabled: true }],
|
||||
verify: async (request) => {
|
||||
if (request.body.password !== "hunter2") {
|
||||
await auth.loginRateLimit.recordFailure(
|
||||
request.ip ?? "unknown",
|
||||
"4:bob",
|
||||
);
|
||||
throw new (await import("@termix/plugin-sdk/backend")).LoginMethodError(
|
||||
"Invalid username or password",
|
||||
401,
|
||||
);
|
||||
}
|
||||
return {
|
||||
kind: "external",
|
||||
provider: "ldap:4",
|
||||
subject: "bob",
|
||||
name: "Bob",
|
||||
legacy: { identifier: "ldap:4:bob", providerRowId: 4 },
|
||||
rateLimitKey: "4:bob",
|
||||
};
|
||||
},
|
||||
});
|
||||
return { auth, bag };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
h.state = createAuthState();
|
||||
h.manager = fakeAuthManager(h.state);
|
||||
h.granted = new Set(["auth:provide"]);
|
||||
h.trustedProxy = false;
|
||||
h.audits = [];
|
||||
resetAuthRegistryForTests();
|
||||
resetCoreLoginProvidersForTests();
|
||||
});
|
||||
|
||||
describe("a plugin's external identity", () => {
|
||||
it("carries the 2.8 identifier, role map and logout claims into core", async () => {
|
||||
setup();
|
||||
const identity = await getLoginMethod("corp-sso")!.callback!(
|
||||
fakeRequest() as never,
|
||||
);
|
||||
const result = await runLogin(fakeRequest() as never, identity, {
|
||||
methodId: "corp-sso",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(result.kind).toBe("session");
|
||||
|
||||
const [user] = [...h.state.users.values()];
|
||||
expect(user).toMatchObject({ oidcIdentifier: "sub-1", ssoProviderId: 3 });
|
||||
expect(h.state.identities).toContainEqual(
|
||||
expect.objectContaining({ providerId: "3", subject: "sub-1" }),
|
||||
);
|
||||
expect(h.state.roles).toContainEqual({ userId: user.id, roleName: "ops" });
|
||||
expect(h.manager.generateJWTToken).toHaveBeenCalledWith(
|
||||
user.id,
|
||||
expect.objectContaining({
|
||||
ssoProviderId: 3,
|
||||
oidcSub: "sub-1",
|
||||
oidcSid: "sid-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("cannot set core-only fields", async () => {
|
||||
const { toCoreIdentity } = await import("../../plugins/ctx-ssh-auth.js");
|
||||
const mapped = toCoreIdentity("corp", {
|
||||
kind: "external",
|
||||
provider: "3",
|
||||
subject: "x",
|
||||
unlockError: "spoofed",
|
||||
rateLimitUsername: "someone-else",
|
||||
} as never);
|
||||
expect(mapped).not.toHaveProperty("unlockError", "spoofed");
|
||||
expect(mapped.rateLimitUsername).toBeUndefined();
|
||||
});
|
||||
|
||||
it("finds a 2.8 user by the old identifier and links the identity", async () => {
|
||||
h.state.users.set("u-1", {
|
||||
id: "u-1",
|
||||
username: "Alice",
|
||||
passwordHash: "",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "sub-1",
|
||||
});
|
||||
setup();
|
||||
const identity = await getLoginMethod("corp-sso")!.callback!(
|
||||
fakeRequest() as never,
|
||||
);
|
||||
await runLogin(fakeRequest() as never, identity, {
|
||||
methodId: "corp-sso",
|
||||
rememberMe: false,
|
||||
});
|
||||
expect(h.state.users.size).toBe(1);
|
||||
expect(h.state.identities).toContainEqual(
|
||||
expect.objectContaining({ userId: "u-1", providerId: "3" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.auth.completeRedirectLogin", () => {
|
||||
it("runs the method's callback and redirects back with a session", async () => {
|
||||
const { auth } = setup();
|
||||
const res = fakeResponse();
|
||||
await auth.completeRedirectLogin(
|
||||
"corp-sso",
|
||||
fakeRequest({ query: { code: "c", state: "s" } }),
|
||||
res,
|
||||
);
|
||||
expect(res.redirectedTo).toBe("https://termix.example/?success=true");
|
||||
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
|
||||
});
|
||||
|
||||
it("refuses a method the plugin did not declare", async () => {
|
||||
const { auth } = setup();
|
||||
await expect(
|
||||
auth.completeRedirectLogin("oidc", fakeRequest(), fakeResponse()),
|
||||
).rejects.toThrow(/not listed/);
|
||||
});
|
||||
|
||||
it("refuses without auth:provide", async () => {
|
||||
const { auth } = setup();
|
||||
h.granted.clear();
|
||||
await expect(
|
||||
auth.completeRedirectLogin("corp-sso", fakeRequest(), fakeResponse()),
|
||||
).rejects.toMatchObject({ name: "PluginCapabilityError" });
|
||||
});
|
||||
|
||||
it("is refused while trusted proxy login is on", async () => {
|
||||
const { auth } = setup();
|
||||
h.trustedProxy = true;
|
||||
const res = fakeResponse();
|
||||
await auth.completeRedirectLogin("corp-sso", fakeRequest(), res);
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.cookies).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the other ctx.auth helpers", () => {
|
||||
it("revokes sessions by logout claims and audits it", async () => {
|
||||
const { auth } = setup();
|
||||
expect(await auth.revokeSessions({ providerId: 3, sid: "sid-1" })).toBe(1);
|
||||
expect(h.manager.revokeSessionsByOidc).toHaveBeenCalledWith({
|
||||
ssoProviderId: 3,
|
||||
sub: null,
|
||||
sid: "sid-1",
|
||||
});
|
||||
expect(h.audits).toContain("auth_sessions_revoked");
|
||||
expect(await auth.revokeSessions({ providerId: 3 })).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps rate limit keys apart per plugin and clears them on success", async () => {
|
||||
const { auth } = setup();
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await auth.loginRateLimit.recordFailure("10.9.9.9", "4:bob");
|
||||
}
|
||||
expect(
|
||||
(await auth.loginRateLimit.isLocked("10.9.9.9", "4:bob")).locked,
|
||||
).toBe(true);
|
||||
// Core's own key for the same name is untouched.
|
||||
expect(loginRateLimiter.isLocked("10.9.9.8", "4:bob").locked).toBe(false);
|
||||
loginRateLimiter.resetAttempts("10.9.9.9", "plugin:corp:4:bob");
|
||||
});
|
||||
|
||||
it("counts linked users for a provider", async () => {
|
||||
const { auth } = setup();
|
||||
h.state.identities.push({
|
||||
id: 1,
|
||||
userId: "u-1",
|
||||
providerId: "ldap:4",
|
||||
subject: "bob",
|
||||
email: null,
|
||||
});
|
||||
expect(await auth.countLinkedUsers("ldap:4")).toBe(1);
|
||||
expect(await auth.countLinkedUsers("3")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a disabled plugin", () => {
|
||||
it("lists its instances while active, for the login screen and 2.8 clients", async () => {
|
||||
setup();
|
||||
const methods = await listPublicLoginMethods();
|
||||
expect(methods.map((method) => method.id)).toEqual(
|
||||
expect.arrayContaining(["corp-sso", "corp-dir"]),
|
||||
);
|
||||
expect(await listLegacySsoProviders()).toEqual([
|
||||
{ id: 3, name: "Corp", type: "oidc", displayOrder: 0 },
|
||||
{ id: 4, name: "Directory", type: "corp-dir", displayOrder: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides its buttons and leaves no route that signs anyone in", async () => {
|
||||
const { bag } = setup();
|
||||
await bag.disposeAll();
|
||||
|
||||
const methods = await listPublicLoginMethods();
|
||||
expect(methods.map((method) => method.id)).toEqual(["password"]);
|
||||
expect(await listLegacySsoProviders()).toEqual([]);
|
||||
|
||||
const start = fakeResponse();
|
||||
await startRedirectLogin(
|
||||
fakeRequest() as never,
|
||||
start as never,
|
||||
"corp-sso",
|
||||
"3",
|
||||
);
|
||||
expect(start.statusCode).toBe(404);
|
||||
|
||||
const verify = fakeResponse();
|
||||
await verifyFormLogin(
|
||||
fakeRequest({ body: { username: "bob", password: "hunter2" } }) as never,
|
||||
verify as never,
|
||||
"corp-dir",
|
||||
"4",
|
||||
);
|
||||
expect(verify.statusCode).toBe(404);
|
||||
expect(verify.cookies).toHaveLength(0);
|
||||
expect(h.state.users.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("form methods and the external second factor setting", () => {
|
||||
function enrolledBob() {
|
||||
h.state.users.set("u-bob", {
|
||||
id: "u-bob",
|
||||
username: "Bob",
|
||||
passwordHash: "",
|
||||
isAdmin: false,
|
||||
isOidc: true,
|
||||
oidcIdentifier: "ldap:4:bob",
|
||||
});
|
||||
h.state.factors.push({
|
||||
userId: "u-bob",
|
||||
pluginId: "fixture",
|
||||
factorId: "pin",
|
||||
});
|
||||
}
|
||||
|
||||
it("skips second factors when the setting is off", async () => {
|
||||
enrolledBob();
|
||||
setup();
|
||||
const res = fakeResponse();
|
||||
await verifyFormLogin(
|
||||
fakeRequest({ body: { password: "hunter2" } }) as never,
|
||||
res as never,
|
||||
"corp-dir",
|
||||
"4",
|
||||
);
|
||||
expect(res.body).toMatchObject({ success: true });
|
||||
expect(res.cookies[0]).toMatchObject({ name: "jwt" });
|
||||
});
|
||||
|
||||
it("asks for them when the setting is on", async () => {
|
||||
enrolledBob();
|
||||
h.state.settings.set("second_factor_after_external_login", "true");
|
||||
setup();
|
||||
const { registerSecondFactor } = await import("../../auth/registry.js");
|
||||
registerSecondFactor({
|
||||
id: "pin",
|
||||
pluginId: "fixture",
|
||||
labelKey: "pin",
|
||||
isEnrolled: async () => false,
|
||||
verify: async () => true,
|
||||
});
|
||||
const res = fakeResponse();
|
||||
await verifyFormLogin(
|
||||
fakeRequest({ body: { password: "hunter2" } }) as never,
|
||||
res as never,
|
||||
"corp-dir",
|
||||
"4",
|
||||
);
|
||||
expect(res.body).toMatchObject({ requires_second_factor: true });
|
||||
});
|
||||
|
||||
it("refuses a wrong password without a session", async () => {
|
||||
setup();
|
||||
const res = fakeResponse();
|
||||
await verifyFormLogin(
|
||||
fakeRequest({ body: { password: "nope" } }) as never,
|
||||
res as never,
|
||||
"corp-dir",
|
||||
"4",
|
||||
);
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.cookies).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -47,7 +47,7 @@ beforeEach(() => {
|
||||
describe("parseLegacyIdentifier", () => {
|
||||
it("splits the prefixed forms and keeps bare subjects on their provider", () => {
|
||||
expect(parseLegacyIdentifier("ldap:4:bob", null)).toEqual({
|
||||
providerId: "4",
|
||||
providerId: "ldap:4",
|
||||
subject: "bob",
|
||||
});
|
||||
expect(parseLegacyIdentifier("github:7:42", null)).toEqual({
|
||||
@@ -55,7 +55,7 @@ describe("parseLegacyIdentifier", () => {
|
||||
subject: "42",
|
||||
});
|
||||
expect(parseLegacyIdentifier("ldap:4:cn=a:b", null)).toEqual({
|
||||
providerId: "4",
|
||||
providerId: "ldap:4",
|
||||
subject: "cn=a:b",
|
||||
});
|
||||
expect(parseLegacyIdentifier("sub-123", 3)).toEqual({
|
||||
@@ -94,7 +94,7 @@ describe("runExternalIdentityMigration", () => {
|
||||
subject,
|
||||
})),
|
||||
).toEqual([
|
||||
{ userId: "ldap-user", providerId: "4", subject: "bob" },
|
||||
{ userId: "ldap-user", providerId: "ldap:4", subject: "bob" },
|
||||
{ userId: "gh-user", providerId: "7", subject: "42" },
|
||||
{ userId: "oidc-user", providerId: "3", subject: "sub-123" },
|
||||
{
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* The 2.8 to 2.9 LDAP split. LDAP rows in sso_providers (adopted or not yet)
|
||||
* move into the ldap plugin's table with their ids, their identities carry
|
||||
* the "ldap:" prefix, the SSO rows stay, and running it twice changes nothing.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({ db: null as unknown }));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({ getDb: () => h.db }));
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
return { databaseLogger: log };
|
||||
});
|
||||
|
||||
const { runLdapProviderMigration } =
|
||||
await import("../../utils/crypto-migration/ldap-provider-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE IF NOT EXISTS sso_providers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
display_order INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
const TARGET_DDL = `
|
||||
CREATE TABLE IF NOT EXISTS "p_ldap_providers" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"name" text NOT NULL,
|
||||
"enabled" integer NOT NULL DEFAULT 1,
|
||||
"display_order" integer NOT NULL DEFAULT 0,
|
||||
"config" text NOT NULL,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`;
|
||||
|
||||
function setup(options: { adopted: boolean; target: boolean }) {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
${LEGACY_DDL}
|
||||
CREATE TABLE user_external_identities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
provider_id TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
email TEXT
|
||||
);
|
||||
`);
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO sso_providers (id, name, type, enabled, config) VALUES (?, ?, ?, ?, ?)",
|
||||
);
|
||||
insert.run(3, "Keycloak", "oidc", 1, '{"client_id":"termix"}');
|
||||
insert.run(4, "Corp LDAP", "ldap", 1, '{"host":"ldap.example"}');
|
||||
insert.run(5, "Old LDAP", "ldap", 0, '{"host":"old.example"}');
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run("u-oidc", "3", "sub-1");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run("u-ldap", "ldap:4", "bob");
|
||||
if (options.adopted) {
|
||||
sqlite.exec(`ALTER TABLE sso_providers RENAME TO p_sso_providers;`);
|
||||
}
|
||||
if (options.target) sqlite.exec(TARGET_DDL);
|
||||
h.db = drizzle(sqlite);
|
||||
}
|
||||
|
||||
const all = (query: string) => sqlite.prepare(query).all();
|
||||
|
||||
describe("runLdapProviderMigration", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("does nothing until the ldap plugin's table exists", async () => {
|
||||
setup({ adopted: false, target: false });
|
||||
expect(await runLdapProviderMigration()).toEqual({
|
||||
moved: 0,
|
||||
renumbered: 0,
|
||||
});
|
||||
expect(all("SELECT id FROM sso_providers")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the legacy table", false],
|
||||
["the adopted table", true],
|
||||
])("moves LDAP rows out of %s with their ids", async (_label, adopted) => {
|
||||
setup({ adopted, target: true });
|
||||
const source = adopted ? "p_sso_providers" : "sso_providers";
|
||||
|
||||
expect(await runLdapProviderMigration()).toEqual({
|
||||
moved: 2,
|
||||
renumbered: 0,
|
||||
});
|
||||
expect(
|
||||
all("SELECT id, name, enabled, config FROM p_ldap_providers ORDER BY id"),
|
||||
).toEqual([
|
||||
{
|
||||
id: 4,
|
||||
name: "Corp LDAP",
|
||||
enabled: 1,
|
||||
config: '{"host":"ldap.example"}',
|
||||
},
|
||||
{ id: 5, name: "Old LDAP", enabled: 0, config: '{"host":"old.example"}' },
|
||||
]);
|
||||
expect(all(`SELECT id, type FROM ${source}`)).toEqual([
|
||||
{ id: 3, type: "oidc" },
|
||||
]);
|
||||
expect(
|
||||
all("SELECT user_id, provider_id FROM user_external_identities"),
|
||||
).toEqual([
|
||||
{ user_id: "u-oidc", provider_id: "3" },
|
||||
{ user_id: "u-ldap", provider_id: "ldap:4" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("renames bare identities left from before the prefix", async () => {
|
||||
setup({ adopted: true, target: true });
|
||||
sqlite.exec(
|
||||
"UPDATE user_external_identities SET provider_id = '4' WHERE user_id = 'u-ldap'",
|
||||
);
|
||||
await runLdapProviderMigration();
|
||||
expect(
|
||||
all(
|
||||
"SELECT provider_id FROM user_external_identities WHERE user_id = 'u-ldap'",
|
||||
),
|
||||
).toEqual([{ provider_id: "ldap:4" }]);
|
||||
});
|
||||
|
||||
it("gives a row a new id when the plugin already used it", async () => {
|
||||
setup({ adopted: true, target: true });
|
||||
sqlite.exec(
|
||||
`INSERT INTO p_ldap_providers (id, name, config) VALUES (4, 'New', '{}')`,
|
||||
);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO user_external_identities (user_id, provider_id, subject) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run("u-bare", "4", "carol");
|
||||
|
||||
const result = await runLdapProviderMigration();
|
||||
expect(result).toEqual({ moved: 2, renumbered: 1 });
|
||||
const moved = all(
|
||||
"SELECT id FROM p_ldap_providers WHERE name = 'Corp LDAP'",
|
||||
) as Array<{ id: number }>;
|
||||
expect(moved[0].id).not.toBe(4);
|
||||
expect(
|
||||
all(
|
||||
"SELECT provider_id FROM user_external_identities WHERE user_id = 'u-bare'",
|
||||
),
|
||||
).toEqual([{ provider_id: `ldap:${moved[0].id}` }]);
|
||||
});
|
||||
|
||||
it("is idempotent", async () => {
|
||||
setup({ adopted: true, target: true });
|
||||
await runLdapProviderMigration();
|
||||
expect(await runLdapProviderMigration()).toEqual({
|
||||
moved: 0,
|
||||
renumbered: 0,
|
||||
});
|
||||
expect(all("SELECT id FROM p_ldap_providers")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getDesktopOidcCallbackUrl,
|
||||
isOidcTokenCallback,
|
||||
} from "../../utils/oidc-desktop-callback.js";
|
||||
|
||||
describe("getDesktopOidcCallbackUrl", () => {
|
||||
it("uses localhost so browsers do not upgrade the loopback callback", () => {
|
||||
expect(getDesktopOidcCallbackUrl("17850")).toBe(
|
||||
"http://localhost:17850/oidc-callback",
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["", "0", "65536", "17850/path", ["17850"]])(
|
||||
"rejects invalid callback port %j",
|
||||
(port) => {
|
||||
expect(getDesktopOidcCallbackUrl(port)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
import { isOidcTokenCallback } from "../../utils/oidc-desktop-callback.js";
|
||||
|
||||
describe("isOidcTokenCallback", () => {
|
||||
it.each([
|
||||
|
||||
@@ -8,14 +8,8 @@ vi.mock("../../utils/system-crypto.js", () => ({
|
||||
SystemCrypto: { getInstance: () => ({ getEncryptionKey }) },
|
||||
}));
|
||||
|
||||
const {
|
||||
decryptSsoConfigSecrets,
|
||||
decryptSystemSecret,
|
||||
encryptSsoConfigSecrets,
|
||||
encryptSystemSecret,
|
||||
isSystemEncrypted,
|
||||
SSO_SECRET_FIELDS,
|
||||
} = await import("../../utils/system-secret-crypto.js");
|
||||
const { decryptSystemSecret, encryptSystemSecret, isSystemEncrypted } =
|
||||
await import("../../utils/system-secret-crypto.js");
|
||||
|
||||
beforeEach(() => {
|
||||
getEncryptionKey.mockReset();
|
||||
@@ -103,55 +97,3 @@ describe("legacy compatibility", () => {
|
||||
await expect(decryptSystemSecret(sealed)).resolves.toBe("old-secret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSO provider config", () => {
|
||||
it("seals only the secret fields", async () => {
|
||||
const sealed = await encryptSsoConfigSecrets({
|
||||
client_id: "termix",
|
||||
client_secret: "shhh",
|
||||
bindPassword: "ldap-pw",
|
||||
issuer_url: "https://idp.example",
|
||||
});
|
||||
|
||||
expect(sealed.client_id).toBe("termix");
|
||||
expect(sealed.issuer_url).toBe("https://idp.example");
|
||||
expect(isSystemEncrypted(sealed.client_secret as string)).toBe(true);
|
||||
expect(isSystemEncrypted(sealed.bindPassword as string)).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips a whole config", async () => {
|
||||
const original = {
|
||||
client_id: "termix",
|
||||
client_secret: "shhh",
|
||||
bindPassword: "ldap-pw",
|
||||
};
|
||||
|
||||
const restored = await decryptSsoConfigSecrets(
|
||||
await encryptSsoConfigSecrets(original),
|
||||
);
|
||||
|
||||
expect(restored).toEqual(original);
|
||||
});
|
||||
|
||||
it("covers both secret fields", () => {
|
||||
expect([...SSO_SECRET_FIELDS]).toEqual(["client_secret", "bindPassword"]);
|
||||
});
|
||||
|
||||
it("leaves a config without secrets untouched", async () => {
|
||||
const config = { client_id: "termix", scopes: "openid" };
|
||||
|
||||
await expect(encryptSsoConfigSecrets(config)).resolves.toEqual(config);
|
||||
await expect(decryptSsoConfigSecrets(config)).resolves.toEqual(config);
|
||||
});
|
||||
|
||||
it("does not let one unreadable secret take down the provider", async () => {
|
||||
const restored = await decryptSsoConfigSecrets({
|
||||
client_id: "termix",
|
||||
client_secret: "sysenc:v1:bad",
|
||||
});
|
||||
|
||||
// The rest of the config survives; login fails later with a clearer error.
|
||||
expect(restored.client_id).toBe("termix");
|
||||
expect(restored.client_secret).toBe("sysenc:v1:bad");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,8 @@
|
||||
* users.oidc_identifier packed provider and subject into one string:
|
||||
* "ldap:<provider>:<id>", "github:<provider>:<id>", or a bare OIDC subject
|
||||
* whose provider was users.sso_provider_id. Each becomes a
|
||||
* user_external_identities row. TOTP enrolment moves in totp-migration.ts.
|
||||
* user_external_identities row; LDAP ones under provider "ldap:<provider>",
|
||||
* the id the ldap plugin signs in with. TOTP enrolment moves in totp-migration.ts.
|
||||
*
|
||||
* Lossless: the old column is left as it is. Idempotent: rows that already
|
||||
* exist are skipped, so it runs on every boot.
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
createCurrentUserRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
|
||||
/** Matches LEGACY_OIDC_PROVIDER_ID in auth/legacy/oidc-login.ts. */
|
||||
/** The provider id the sso plugin gives its env-configured provider. */
|
||||
const LEGACY_OIDC_PROVIDER_ID = "legacy-oidc";
|
||||
|
||||
export interface ExternalIdentityMigrationResult {
|
||||
@@ -34,11 +35,16 @@ export function parseLegacyIdentifier(
|
||||
const prefixed = /^(ldap|github):([^:]+):(.+)$/.exec(identifier);
|
||||
if (prefixed) {
|
||||
const provider = prefixed[2];
|
||||
const missing = provider === "null" || provider === "undefined";
|
||||
// LDAP providers moved to their own table in 2.9 and their identities
|
||||
// carry the "ldap:" prefix so their ids cannot clash with SSO ones.
|
||||
if (prefixed[1] === "ldap") {
|
||||
return missing
|
||||
? null
|
||||
: { providerId: `ldap:${provider}`, subject: prefixed[3] };
|
||||
}
|
||||
return {
|
||||
providerId:
|
||||
provider === "null" || provider === "undefined"
|
||||
? LEGACY_OIDC_PROVIDER_ID
|
||||
: provider,
|
||||
providerId: missing ? LEGACY_OIDC_PROVIDER_ID : provider,
|
||||
subject: prefixed[3],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Moves 2.8 LDAP directories into the ldap plugin.
|
||||
*
|
||||
* 2.8 kept LDAP and SSO providers in one sso_providers table. The sso plugin
|
||||
* adopts that table (as p_sso_providers) and ignores its LDAP rows; this
|
||||
* copies them into p_ldap_providers once the ldap plugin has created it, then
|
||||
* deletes them from the source.
|
||||
*
|
||||
* Ids are kept, because users.oidc_identifier ("ldap:<id>:<uid>") and their
|
||||
* identities ("ldap:<id>") name them. Bare "<id>" identities from before the
|
||||
* prefix are renamed too. It runs before the ldap plugin first activates, so
|
||||
* the plugin cannot have used an id yet; if it somehow has, the old row gets
|
||||
* a fresh id and only its bare identities follow it.
|
||||
*
|
||||
* Needs neither plugin to be running, only their tables. Idempotent.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { databaseLogger } from "../logger.js";
|
||||
import { resolveDatabaseDialect } from "../../database/db/dialect.js";
|
||||
import { runStatement, selectRows } from "./raw-rows.js";
|
||||
|
||||
const TARGET = "p_ldap_providers";
|
||||
const SOURCES = ["p_sso_providers", "sso_providers"];
|
||||
|
||||
interface LegacyRow {
|
||||
id: number;
|
||||
name: string;
|
||||
enabled: unknown;
|
||||
display_order: number;
|
||||
config: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LdapProviderMigrationResult {
|
||||
/** Directories copied into the plugin's table. */
|
||||
moved: number;
|
||||
/** Of those, how many needed a new id. */
|
||||
renumbered: number;
|
||||
}
|
||||
|
||||
async function tableExists(name: string): Promise<boolean> {
|
||||
try {
|
||||
await selectRows(sql`SELECT 1 FROM ${sql.identifier(name)} WHERE 1 = 0`);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isOn(value: unknown): boolean {
|
||||
return value === true || value === 1 || value === "1" || value === "t";
|
||||
}
|
||||
|
||||
async function renameIdentities(from: string, to: string): Promise<void> {
|
||||
await runStatement(
|
||||
sql`UPDATE user_external_identities SET provider_id = ${to} WHERE provider_id = ${from}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Explicit ids leave a Postgres sequence behind them. */
|
||||
async function syncSequence(): Promise<void> {
|
||||
if (resolveDatabaseDialect() !== "postgres") return;
|
||||
await runStatement(
|
||||
sql`SELECT setval(pg_get_serial_sequence(${TARGET}, 'id'), (SELECT COALESCE(MAX(id), 1) FROM ${sql.identifier(TARGET)}))`,
|
||||
);
|
||||
}
|
||||
|
||||
async function insertRow(row: LegacyRow, id: number | null): Promise<number> {
|
||||
const enabled = isOn(row.enabled);
|
||||
const values = sql`${row.name}, ${resolveDatabaseDialect() === "sqlite" ? (enabled ? 1 : 0) : enabled}, ${row.display_order ?? 0}, ${row.config}, ${row.created_at}, ${row.updated_at}`;
|
||||
if (id !== null) {
|
||||
await runStatement(
|
||||
sql`INSERT INTO ${sql.identifier(TARGET)} (id, name, enabled, display_order, config, created_at, updated_at) VALUES (${id}, ${values})`,
|
||||
);
|
||||
return id;
|
||||
}
|
||||
await runStatement(
|
||||
sql`INSERT INTO ${sql.identifier(TARGET)} (name, enabled, display_order, config, created_at, updated_at) VALUES (${values})`,
|
||||
);
|
||||
const [inserted] = await selectRows<{ id: number }>(
|
||||
sql`SELECT MAX(id) AS id FROM ${sql.identifier(TARGET)}`,
|
||||
);
|
||||
return Number(inserted.id);
|
||||
}
|
||||
|
||||
export async function runLdapProviderMigration(): Promise<LdapProviderMigrationResult> {
|
||||
const result: LdapProviderMigrationResult = { moved: 0, renumbered: 0 };
|
||||
try {
|
||||
if (!(await tableExists(TARGET))) return result;
|
||||
|
||||
for (const source of SOURCES) {
|
||||
if (!(await tableExists(source))) continue;
|
||||
const rows = await selectRows<LegacyRow>(
|
||||
sql`SELECT id, name, enabled, display_order, config, created_at, updated_at FROM ${sql.identifier(source)} WHERE type = 'ldap' ORDER BY id`,
|
||||
);
|
||||
|
||||
// Rows whose id is free keep it; the rest go last, so a fresh id can
|
||||
// never land on one that is still waiting to be copied.
|
||||
const clashing: LegacyRow[] = [];
|
||||
for (const row of rows) {
|
||||
const taken = await selectRows(
|
||||
sql`SELECT id FROM ${sql.identifier(TARGET)} WHERE id = ${row.id}`,
|
||||
);
|
||||
if (taken.length > 0) {
|
||||
clashing.push(row);
|
||||
continue;
|
||||
}
|
||||
await insertRow(row, row.id);
|
||||
await renameIdentities(String(row.id), `ldap:${row.id}`);
|
||||
await runStatement(
|
||||
sql`DELETE FROM ${sql.identifier(source)} WHERE id = ${row.id}`,
|
||||
);
|
||||
result.moved++;
|
||||
}
|
||||
if (clashing.length > 0) await syncSequence();
|
||||
for (const row of clashing) {
|
||||
const id = await insertRow(row, null);
|
||||
// "ldap:<id>" already names the plugin's own directory, so only the
|
||||
// bare ids from before the prefix can follow this one.
|
||||
await renameIdentities(String(row.id), `ldap:${id}`);
|
||||
await runStatement(
|
||||
sql`DELETE FROM ${sql.identifier(source)} WHERE id = ${row.id}`,
|
||||
);
|
||||
result.moved++;
|
||||
result.renumbered++;
|
||||
}
|
||||
}
|
||||
|
||||
if (result.moved > 0) await syncSequence();
|
||||
|
||||
if (result.moved > 0) {
|
||||
databaseLogger.info("Moved LDAP directories into the ldap plugin", {
|
||||
operation: "ldap_provider_migration",
|
||||
...result,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
databaseLogger.error("LDAP directory migration failed", error, {
|
||||
operation: "ldap_provider_migration_failed",
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Core data that moves into a plugin's tables once they exist. Runs whenever
|
||||
* a plugin's migrations applied something, at boot or when a plugin is
|
||||
* enabled later, so the data is in place before the plugin's activate runs. Each move is
|
||||
* idempotent and does nothing until its target table exists.
|
||||
*/
|
||||
|
||||
import { runLdapProviderMigration } from "./ldap-provider-migration.js";
|
||||
|
||||
export async function runPluginDataMoves(): Promise<void> {
|
||||
await runLdapProviderMigration();
|
||||
}
|
||||
@@ -1,14 +1,10 @@
|
||||
const CALLBACK_PATH = "/oidc-callback";
|
||||
|
||||
export function getDesktopOidcCallbackUrl(value: unknown): string | null {
|
||||
if (typeof value !== "string" || !/^\d+$/.test(value)) return null;
|
||||
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
||||
|
||||
return `http://localhost:${port}${CALLBACK_PATH}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a redirect login's return address is the desktop app's loopback
|
||||
* listener or the mobile app's scheme, which get the token in the URL
|
||||
* because they cannot read a cookie.
|
||||
*/
|
||||
export function isOidcTokenCallback(value: string): boolean {
|
||||
if (value.startsWith("termix-mobile:")) return true;
|
||||
|
||||
|
||||
@@ -163,10 +163,6 @@ const swaggerOptions: SwaggerJSDocOptions = {
|
||||
name: "API Keys",
|
||||
description: "API key management",
|
||||
},
|
||||
{
|
||||
name: "SSO",
|
||||
description: "Single sign-on provider configuration",
|
||||
},
|
||||
{
|
||||
name: "Vault",
|
||||
description: "HashiCorp Vault SSH signing profiles",
|
||||
|
||||
@@ -85,37 +85,3 @@ export async function decryptSystemSecret(stored: string): Promise<string> {
|
||||
decipher.final(),
|
||||
]).toString("utf8");
|
||||
}
|
||||
|
||||
/** Fields inside an SSO provider config that must not be stored readable. */
|
||||
export const SSO_SECRET_FIELDS = ["client_secret", "bindPassword"] as const;
|
||||
|
||||
export async function encryptSsoConfigSecrets(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const out = { ...config };
|
||||
for (const field of SSO_SECRET_FIELDS) {
|
||||
const value = out[field];
|
||||
if (typeof value === "string" && value) {
|
||||
out[field] = await encryptSystemSecret(value);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptSsoConfigSecrets(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const out = { ...config };
|
||||
for (const field of SSO_SECRET_FIELDS) {
|
||||
const value = out[field];
|
||||
if (typeof value === "string" && value) {
|
||||
try {
|
||||
out[field] = await decryptSystemSecret(value);
|
||||
} catch {
|
||||
// A secret we cannot read must not take the whole provider down;
|
||||
// login will fail with a clearer error downstream.
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -8,57 +8,6 @@ export type {
|
||||
HostAuthOverrides,
|
||||
} from "./auth-protocols.js";
|
||||
|
||||
// ============================================================================
|
||||
// SSO / AUTHENTICATION PROVIDER TYPES
|
||||
// ============================================================================
|
||||
|
||||
export type SSOProviderType = "oidc" | "ldap" | "github" | "google";
|
||||
|
||||
export interface SSOProviderPublic {
|
||||
id: number;
|
||||
name: string;
|
||||
type: SSOProviderType;
|
||||
displayOrder: number;
|
||||
}
|
||||
|
||||
export interface SSOProvider extends SSOProviderPublic {
|
||||
enabled: boolean;
|
||||
config: string | Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface OIDCProviderConfig {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
userinfo_url?: string;
|
||||
identifier_path: string;
|
||||
name_path: string;
|
||||
scopes: string;
|
||||
allowed_users?: string;
|
||||
admin_group?: string;
|
||||
group_claim?: string;
|
||||
ca_cert?: string;
|
||||
}
|
||||
|
||||
export interface LDAPProviderConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
useTLS: boolean;
|
||||
bindDN: string;
|
||||
bindPassword: string;
|
||||
userSearchBase: string;
|
||||
userSearchFilter: string;
|
||||
usernameAttribute: string;
|
||||
displayNameAttribute: string;
|
||||
groupSearchBase?: string;
|
||||
adminGroup?: string;
|
||||
allowedUsers?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HOST TYPES (SSH, RDP, VNC, Telnet)
|
||||
// ============================================================================
|
||||
|
||||
@@ -328,7 +328,6 @@ export type DashboardCardConfig = {
|
||||
|
||||
export type AdminSection =
|
||||
| "general"
|
||||
| "sso"
|
||||
| "users"
|
||||
| "sessions"
|
||||
| "roles"
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { authApi, handleApiError } from "@/main-axios";
|
||||
import type { SSOProvider, SSOProviderPublic } from "@/types/index";
|
||||
|
||||
export async function getSSOProviders(): Promise<SSOProviderPublic[]> {
|
||||
try {
|
||||
const response = await authApi.get("/users/sso-providers");
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
console.warn("Failed to fetch SSO providers:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminSSOProviders(): Promise<SSOProvider[]> {
|
||||
try {
|
||||
const response = await authApi.get("/users/sso-providers/admin");
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch admin SSO providers");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSSOProvider(
|
||||
data: Omit<SSOProvider, "id" | "createdAt" | "updatedAt"> & {
|
||||
config: Record<string, unknown>;
|
||||
},
|
||||
): Promise<SSOProvider> {
|
||||
try {
|
||||
const response = await authApi.post("/users/sso-providers", data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "create SSO provider");
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSSOProvider(
|
||||
id: number,
|
||||
data: Partial<Omit<SSOProvider, "id" | "createdAt" | "updatedAt">> & {
|
||||
config?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<SSOProvider> {
|
||||
try {
|
||||
const response = await authApi.put(`/users/sso-providers/${id}`, data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update SSO provider");
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteSSOProvider(id: number): Promise<void> {
|
||||
try {
|
||||
await authApi.delete(`/users/sso-providers/${id}`);
|
||||
} catch (error) {
|
||||
handleApiError(error, "delete SSO provider");
|
||||
}
|
||||
}
|
||||
|
||||
export async function ldapLogin(
|
||||
providerId: number,
|
||||
username: string,
|
||||
password: string,
|
||||
rememberMe: boolean,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await authApi.post("/users/ldap/login", {
|
||||
providerId,
|
||||
username,
|
||||
password,
|
||||
rememberMe,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "LDAP login");
|
||||
}
|
||||
}
|
||||
@@ -330,24 +330,4 @@ export async function updatePasswordResetAllowed(
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateOIDCConfig(
|
||||
config: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.post("/users/oidc-config", config);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "update OIDC config");
|
||||
}
|
||||
}
|
||||
|
||||
export async function disableOIDCConfig(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.delete("/users/oidc-config");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "disable OIDC config");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -1,146 +1,11 @@
|
||||
/**
|
||||
* Login UI that still lives in core for 2.9.0 (OIDC, LDAP) and the
|
||||
* certificate SSH auth editors, registered through the same registries a plugin uses.
|
||||
* The certificate SSH auth editors that still live in core for 2.9.0,
|
||||
* registered through the same registry a plugin uses.
|
||||
* Phase C moves each into its plugin by moving its block out of this file;
|
||||
* D1 deletes the file once it is empty.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { LoginMethodUIProps } from "@termix/plugin-sdk/frontend";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
registerLoginMethod,
|
||||
registerSshAuthEditor,
|
||||
} from "@/plugin-host/auth-registry";
|
||||
|
||||
const primaryButton =
|
||||
"w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold";
|
||||
|
||||
function OidcLoginButtons({
|
||||
instances,
|
||||
disabled,
|
||||
startRedirect,
|
||||
}: LoginMethodUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
return (
|
||||
<>
|
||||
{instances.map((instance) => (
|
||||
<Button
|
||||
key={instance.id}
|
||||
onClick={async () => {
|
||||
setLoadingId(instance.id);
|
||||
try {
|
||||
await startRedirect(instance.id);
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
}}
|
||||
disabled={disabled || loadingId !== null}
|
||||
className={primaryButton}
|
||||
>
|
||||
{loadingId === instance.id
|
||||
? t("common.loading")
|
||||
: t("auth.loginWithProvider", { name: instance.label })}
|
||||
</Button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LdapLoginForms({ instances, disabled, submit }: LoginMethodUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
}, [expandedId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{instances.map((instance) => {
|
||||
const expanded = expandedId === instance.id;
|
||||
return (
|
||||
<div key={instance.id} className="flex flex-col border border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedId(expanded ? null : instance.id)}
|
||||
className="flex items-center justify-between w-full px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<span>
|
||||
{t("auth.loginWithProvider", { name: instance.label })}
|
||||
</span>
|
||||
{expanded ? (
|
||||
<ChevronUp className="size-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<form
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (!username.trim() || !password) {
|
||||
toast.error(t("errors.requiredField"));
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
await submit(
|
||||
{ username: username.trim(), password },
|
||||
instance.id,
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
className="flex flex-col gap-3 p-3 border-t border-border"
|
||||
>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("auth.ldapUsername")}
|
||||
</span>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("auth.ldapPassword")}
|
||||
</span>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={disabled || loading}
|
||||
className="font-mono"
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
type="submit"
|
||||
className={primaryButton}
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
{loading ? t("common.loading") : t("auth.ldapSignIn")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { registerSshAuthEditor } from "@/plugin-host/auth-registry";
|
||||
|
||||
function CertificateAuthInfo({
|
||||
labelKey,
|
||||
@@ -197,18 +62,6 @@ let registered = false;
|
||||
export function ensureLegacyAuthUI(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
registerLoginMethod({
|
||||
id: "oidc",
|
||||
pluginId: "core",
|
||||
titleKey: "auth.loginWithSso",
|
||||
component: OidcLoginButtons,
|
||||
});
|
||||
registerLoginMethod({
|
||||
id: "ldap",
|
||||
pluginId: "core",
|
||||
titleKey: "auth.loginWithLdap",
|
||||
component: LdapLoginForms,
|
||||
});
|
||||
registerSshAuthEditor({
|
||||
id: "opkssh",
|
||||
pluginId: "core",
|
||||
|
||||
@@ -1489,10 +1489,6 @@
|
||||
"externalNotSupportedInElectron": "External authentication is not supported in the Electron app yet. Please use the web version for OIDC login.",
|
||||
"loginWithProvider": "Login with {{name}}",
|
||||
"orContinueWith": "or continue with",
|
||||
"ldapUsername": "LDAP Username",
|
||||
"ldapPassword": "LDAP Password",
|
||||
"ldapSignIn": "Sign In",
|
||||
"ldapLoginFailed": "LDAP login failed",
|
||||
"resetPasswordButton": "Reset Password",
|
||||
"sendResetCode": "Send Reset Code",
|
||||
"resetCodeDesc": "Enter your username to receive a password reset code. The code will be logged in the docker container logs.",
|
||||
@@ -1512,8 +1508,6 @@
|
||||
"passwordLoginDisabledDesc": "Password login is disabled. Use a passkey or an external authentication provider.",
|
||||
"attemptsRemaining": "{{count}} attempts remaining",
|
||||
"confirmResetDataWipe": "This account has not logged in since the encryption upgrade, so its stored data cannot be recovered without the old password. Resetting will permanently delete its hosts, credentials and snippets. Continue?",
|
||||
"loginWithSso": "Sign in with SSO",
|
||||
"loginWithLdap": "Sign in with LDAP",
|
||||
"password": "Password",
|
||||
"secondFactorUnavailable": "Your account uses a second factor that is not available right now. Contact an admin to reset it.",
|
||||
"secondFactorNoUI": "This second factor cannot be shown on this screen. Contact an admin."
|
||||
@@ -1662,37 +1656,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "General",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "SSO Providers",
|
||||
"ssoAddProvider": "Add Provider",
|
||||
"ssoDocsLink": "View docs",
|
||||
"ssoProviderDocsLink": "View docs",
|
||||
"ssoNoProviders": "No SSO providers configured.",
|
||||
"ssoProviderName": "Display Name",
|
||||
"ssoProviderType": "Provider Type",
|
||||
"ssoDeleteProvider": "Delete Provider",
|
||||
"ssoDeleteConfirm": "Delete this provider? Associated users will be unable to login.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Enabled",
|
||||
"ssoDisabled": "Disabled",
|
||||
"ssoSaveProvider": "Save Provider",
|
||||
"ssoTestConnection": "Test Connection",
|
||||
"ssoEditProvider": "Edit Provider",
|
||||
"ldapHost": "LDAP Host",
|
||||
"ldapPort": "Port",
|
||||
"ldapUseTls": "Use TLS (LDAPS)",
|
||||
"ldapBindDn": "Bind DN",
|
||||
"ldapBindPassword": "Bind Password",
|
||||
"ldapUserSearchBase": "User Search Base",
|
||||
"ldapUserSearchFilter": "User Search Filter",
|
||||
"ldapUsernameAttr": "Username Attribute",
|
||||
"ldapDisplayNameAttr": "Display Name Attribute",
|
||||
"ldapGroupSearchBase": "Group Search Base",
|
||||
"ldapAdminGroup": "Admin Group",
|
||||
"ldapAllowedUsers": "Allowed Users",
|
||||
"sectionUsers": "Users",
|
||||
"sectionSessions": "Sessions",
|
||||
"sectionRoles": "Roles",
|
||||
@@ -1800,26 +1763,6 @@
|
||||
"statusCheck": "Status Check",
|
||||
"sec": "sec",
|
||||
"logLevel": "Log Level",
|
||||
"oidcDescription": "Configure OpenID Connect for SSO. Fields marked * are required.",
|
||||
"oidcDocsLink": "View docs",
|
||||
"oidcClientId": "Client ID",
|
||||
"oidcClientSecret": "Client Secret",
|
||||
"oidcAuthUrl": "Authorization URL",
|
||||
"oidcIssuerUrl": "Issuer URL",
|
||||
"oidcTokenUrl": "Token URL",
|
||||
"oidcUserIdentifier": "User Identifier Path",
|
||||
"oidcDisplayName": "Display Name Path",
|
||||
"oidcScopes": "Scopes",
|
||||
"oidcUserinfoUrl": "Override Userinfo URL",
|
||||
"oidcAllowedUsers": "Allowed Users",
|
||||
"oidcAllowedUsersDesc": "One email per line. Leave empty to allow all.",
|
||||
"oidcAdminGroup": "Admin Group",
|
||||
"oidcAdminGroupDesc": "Users in this group are granted admin. Leave empty to disable group sync.",
|
||||
"oidcGroupClaim": "Group Claim",
|
||||
"oidcGroupClaimDesc": "Optional. The claim path that contains the user's groups. Defaults to groups, roles, then group. Use this for providers with a custom claim (e.g. Zitadel).",
|
||||
"oidcCaCert": "Custom CA Certificate",
|
||||
"oidcCaCertDesc": "Optional. PEM-encoded CA certificate for OIDC providers using a private or self-signed CA. Leave empty to use the system trust store.",
|
||||
"removeOidc": "Remove",
|
||||
"usersCount": "{{count}} users",
|
||||
"searchUsers": "Search users...",
|
||||
"noUsers": "No users",
|
||||
@@ -1945,10 +1888,6 @@
|
||||
"monitoringSaved": "Monitoring settings saved",
|
||||
"monitoringSaveFailed": "Failed to save monitoring settings",
|
||||
"logLevelUpdateFailed": "Failed to update log level",
|
||||
"oidcSaved": "OIDC configuration saved",
|
||||
"oidcSaveFailed": "Failed to save OIDC config",
|
||||
"oidcRemoved": "OIDC configuration removed",
|
||||
"oidcRemoveFailed": "Failed to remove OIDC config",
|
||||
"createUserRequired": "Username and password are required",
|
||||
"createUserPasswordTooShort": "Password must be at least 6 characters",
|
||||
"createUserSuccess": "User \"{{username}}\" created",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Eksterne verifikasie word nog nie in die Electron-app ondersteun nie. Gebruik asseblief die webweergawe vir OIDC-aanmelding.",
|
||||
"loginWithProvider": "Teken aan met {{name}}",
|
||||
"orContinueWith": "of gaan voort met",
|
||||
"ldapUsername": "LDAP-gebruikersnaam",
|
||||
"ldapPassword": "LDAP-wagwoord",
|
||||
"ldapSignIn": "Aanmeld",
|
||||
"ldapLoginFailed": "LDAP-aanmelding het misluk",
|
||||
"resetPasswordButton": "Herstel wagwoord",
|
||||
"sendResetCode": "Stuur Herstelkode",
|
||||
"resetCodeDesc": "Voer jou gebruikersnaam in om 'n wagwoordherstelkode te ontvang. Die kode sal in die docker-houerlogboeke aangeteken word.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Algemeen",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "SSO-verskaffers",
|
||||
"ssoAddProvider": "Voeg Verskaffer by",
|
||||
"ssoDocsLink": "Bekyk dokumente",
|
||||
"ssoProviderDocsLink": "Bekyk dokumente",
|
||||
"ssoNoProviders": "Geen SSO-verskaffers gekonfigureer nie.",
|
||||
"ssoProviderName": "Vertoonnaam",
|
||||
"ssoProviderType": "Verskaffertipe",
|
||||
"ssoDeleteProvider": "Vee Verskaffer uit",
|
||||
"ssoDeleteConfirm": "Vee hierdie verskaffer uit? Geassosieerde gebruikers sal nie kan aanmeld nie.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Geaktiveer",
|
||||
"ssoDisabled": "Gestremd",
|
||||
"ssoSaveProvider": "Stoor Verskaffer",
|
||||
"ssoTestConnection": "Toetsverbinding",
|
||||
"ssoEditProvider": "Wysig Verskaffer",
|
||||
"ldapHost": "LDAP-gasheer",
|
||||
"ldapPort": "Hawe",
|
||||
"ldapUseTls": "Gebruik TLS (LDAPS)",
|
||||
"ldapBindDn": "Bind DN",
|
||||
"ldapBindPassword": "Bind Wagwoord",
|
||||
"ldapUserSearchBase": "Gebruikersoekbasis",
|
||||
"ldapUserSearchFilter": "Gebruikersoekfilter",
|
||||
"ldapUsernameAttr": "Gebruikersnaam-kenmerk",
|
||||
"ldapDisplayNameAttr": "Vertoonnaam-kenmerk",
|
||||
"ldapGroupSearchBase": "Groepsoekbasis",
|
||||
"ldapAdminGroup": "Admingroep",
|
||||
"ldapAllowedUsers": "Toegelate Gebruikers",
|
||||
"sectionUsers": "Gebruikers",
|
||||
"sectionSessions": "Sessies",
|
||||
"sectionRoles": "Rolle",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Bekyk dokumente",
|
||||
"tailscaleApiBaseUrl": "API-basis-URL",
|
||||
"tailscaleApiBaseUrlDescription": "Opsioneel. Wys na 'n selfgehoste Headscale-instansie in plaas van api.tailscale.com. Los leeg om die Tailscale-verstek te gebruik.",
|
||||
"oidcDescription": "Konfigureer OpenID Connect vir SSO. Velde gemerk met * is verpligtend.",
|
||||
"oidcDocsLink": "Bekyk dokumente",
|
||||
"oidcClientId": "Kliënt-ID",
|
||||
"oidcClientSecret": "Kliëntgeheim",
|
||||
"oidcAuthUrl": "Magtigings-URL",
|
||||
"oidcIssuerUrl": "Uitreiker-URL",
|
||||
"oidcTokenUrl": "Teken-URL",
|
||||
"oidcUserIdentifier": "Gebruikersidentifiseerderpad",
|
||||
"oidcDisplayName": "Vertoonnaampad",
|
||||
"oidcScopes": "Omvangsgebiede",
|
||||
"oidcUserinfoUrl": "Oorskryf Gebruikersinligting URL",
|
||||
"oidcAllowedUsers": "Toegelate Gebruikers",
|
||||
"oidcAllowedUsersDesc": "Een e-pos per reël. Los leeg om alles toe te laat.",
|
||||
"oidcAdminGroup": "Admingroep",
|
||||
"oidcAdminGroupDesc": "Gebruikers in hierdie groep kry administrasie. Los leeg om groepsinchronisasie te deaktiveer.",
|
||||
"oidcGroupClaim": "Groep Eis",
|
||||
"oidcGroupClaimDesc": "Opsioneel. Die eispad wat die gebruiker se groepe bevat. Standaard is groepe, rolle, dan groep. Gebruik dit vir verskaffers met 'n persoonlike eis (bv. Zitadel).",
|
||||
"oidcCaCert": "Pasgemaakte CA-sertifikaat",
|
||||
"oidcCaCertDesc": "Opsioneel. PEM-geënkodeerde CA-sertifikaat vir OIDC-verskaffers wat 'n private of selfgetekende CA gebruik. Los leeg om die stelselvertrouensberging te gebruik.",
|
||||
"removeOidc": "Verwyder",
|
||||
"usersCount": "{{count}} gebruikers",
|
||||
"searchUsers": "Soek gebruikers...",
|
||||
"noUsers": "Geen gebruikers nie",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Stertskaalinstellings gestoor",
|
||||
"tailscaleSettingsSaveFailed": "Kon nie Tailscale-instellings stoor nie",
|
||||
"logLevelUpdateFailed": "Kon nie logvlak opdateer nie",
|
||||
"oidcSaved": "OIDC-konfigurasie gestoor",
|
||||
"oidcSaveFailed": "Kon nie OIDC-konfigurasie stoor nie",
|
||||
"oidcRemoved": "OIDC-konfigurasie verwyder",
|
||||
"oidcRemoveFailed": "Kon nie OIDC-konfigurasie verwyder nie",
|
||||
"createUserRequired": "Gebruikersnaam en wagwoord word vereis",
|
||||
"createUserPasswordTooShort": "Wagwoord moet ten minste 6 karakters wees",
|
||||
"createUserSuccess": "Gebruiker \"{{username}}\" geskep",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "المصادقة الخارجية غير مدعومة في تطبيق Electron بعد. الرجاء استخدام إصدار الويب لتسجيل الدخول عبر OIDC.",
|
||||
"loginWithProvider": "تسجيل الدخول باستخدام {{name}}",
|
||||
"orContinueWith": "أو المتابعة باستخدام",
|
||||
"ldapUsername": "اسم مستخدم LDAP",
|
||||
"ldapPassword": "كلمة مرور LDAP",
|
||||
"ldapSignIn": "تسجيل الدخول",
|
||||
"ldapLoginFailed": "فشل تسجيل الدخول عبر LDAP",
|
||||
"resetPasswordButton": "إعادة تعيين كلمة المرور",
|
||||
"sendResetCode": "إرسال رمز إعادة التعيين",
|
||||
"resetCodeDesc": "أدخل اسم المستخدم لتلقي رمز إعادة تعيين كلمة المرور. سيتم تسجيل الرمز في سجلات حاوية docker.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "عام",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "مزودو SSO",
|
||||
"ssoAddProvider": "إضافة مزود",
|
||||
"ssoDocsLink": "عرض المستندات",
|
||||
"ssoProviderDocsLink": "عرض المستندات",
|
||||
"ssoNoProviders": "لم يتم تكوين أي مزود SSO.",
|
||||
"ssoProviderName": "الاسم المعروض",
|
||||
"ssoProviderType": "نوع المزود",
|
||||
"ssoDeleteProvider": "حذف المزود",
|
||||
"ssoDeleteConfirm": "حذف هذا المزود؟ لن يتمكن المستخدمون المرتبطون من تسجيل الدخول.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "مُمكّن",
|
||||
"ssoDisabled": "معطل",
|
||||
"ssoSaveProvider": "حفظ المزود",
|
||||
"ssoTestConnection": "اختبار الاتصال",
|
||||
"ssoEditProvider": "تعديل المزود",
|
||||
"ldapHost": "مضيف LDAP",
|
||||
"ldapPort": "المنفذ",
|
||||
"ldapUseTls": "استخدام TLS (LDAPS)",
|
||||
"ldapBindDn": "اسم مميز للربط",
|
||||
"ldapBindPassword": "كلمة مرور الارتباط",
|
||||
"ldapUserSearchBase": "قاعدة بحث المستخدمين",
|
||||
"ldapUserSearchFilter": "مرشح بحث المستخدمين",
|
||||
"ldapUsernameAttr": "سمة اسم المستخدم",
|
||||
"ldapDisplayNameAttr": "سمة اسم العرض",
|
||||
"ldapGroupSearchBase": "قاعدة بحث المجموعات",
|
||||
"ldapAdminGroup": "مجموعة المديرين",
|
||||
"ldapAllowedUsers": "المستخدمون المسموح بهم",
|
||||
"sectionUsers": "المستخدمون",
|
||||
"sectionSessions": "الجلسات",
|
||||
"sectionRoles": "الأدوار",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "عرض المستندات",
|
||||
"tailscaleApiBaseUrl": "عنوان URL الأساسي لواجهة برمجة التطبيقات",
|
||||
"tailscaleApiBaseUrlDescription": "اختياري. قم بالإشارة إلى نسخة Headscale مستضافة ذاتيًا بدلاً من api.tailscale.com. اتركه فارغًا لاستخدام الإعداد الافتراضي لـ Tailscale.",
|
||||
"oidcDescription": "تكوين OpenID Connect لتسجيل الدخول الموحّد (SSO). الحقول المميزة بـ * مطلوبة.",
|
||||
"oidcDocsLink": "عرض المستندات",
|
||||
"oidcClientId": "معرف العميل",
|
||||
"oidcClientSecret": "سر العميل",
|
||||
"oidcAuthUrl": "رابط التفويض",
|
||||
"oidcIssuerUrl": "رابط المُصدر",
|
||||
"oidcTokenUrl": "رابط الرمز المميز",
|
||||
"oidcUserIdentifier": "مسار معرّف المستخدم",
|
||||
"oidcDisplayName": "مسار اسم العرض",
|
||||
"oidcScopes": "النطاقات",
|
||||
"oidcUserinfoUrl": "تجاوز رابط Userinfo",
|
||||
"oidcAllowedUsers": "المستخدمون المسموح لهم",
|
||||
"oidcAllowedUsersDesc": "بريد إلكتروني واحد في كل سطر. اتركه فارغًا للسماح للجميع.",
|
||||
"oidcAdminGroup": "مجموعة المسؤولين",
|
||||
"oidcAdminGroupDesc": "يُمنح المستخدمون في هذه المجموعة صلاحيات المسؤول. اتركه فارغًا لتعطيل مزامنة المجموعة.",
|
||||
"oidcGroupClaim": "مطالبة المجموعة",
|
||||
"oidcGroupClaimDesc": "اختياري. مسار المطالبة الذي يحتوي على مجموعات المستخدم. القيمة الافتراضية هي groups، ثم roles، ثم group. استخدم هذا للموفّرين الذين لديهم مطالبة مخصصة (مثل Zitadel).",
|
||||
"oidcCaCert": "شهادة CA مخصصة",
|
||||
"oidcCaCertDesc": "اختياري. شهادة CA بترميز PEM لموفّري OIDC الذين يستخدمون شهادة CA خاصة أو موقّعة ذاتيًا. اتركه فارغًا لاستخدام مخزن الثقة في النظام.",
|
||||
"removeOidc": "إزالة",
|
||||
"usersCount": "{{count}} مستخدم",
|
||||
"searchUsers": "ابحث عن المستخدمين...",
|
||||
"noUsers": "لا يوجد مستخدمون",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "تم حفظ إعدادات Tailscale",
|
||||
"tailscaleSettingsSaveFailed": "فشل حفظ إعدادات Tailscale",
|
||||
"logLevelUpdateFailed": "فشل تحديث مستوى السجل",
|
||||
"oidcSaved": "تم حفظ تكوين OIDC",
|
||||
"oidcSaveFailed": "فشل حفظ تكوين OIDC",
|
||||
"oidcRemoved": "تمت إزالة تكوين OIDC",
|
||||
"oidcRemoveFailed": "فشلت إزالة تكوين OIDC",
|
||||
"createUserRequired": "اسم المستخدم وكلمة المرور مطلوبان",
|
||||
"createUserPasswordTooShort": "يجب أن تتكون كلمة المرور من 6 أحرف على الأقل",
|
||||
"createUserSuccess": "تم إنشاء المستخدم \"{{username}}\"",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Външното удостоверяване все още не се поддържа в приложението Electron. Моля, използвайте уеб версията за вход в OIDC.",
|
||||
"loginWithProvider": "Влезте с {{name}}",
|
||||
"orContinueWith": "или продължете с",
|
||||
"ldapUsername": "Потребителско име на LDAP",
|
||||
"ldapPassword": "LDAP парола",
|
||||
"ldapSignIn": "Вход",
|
||||
"ldapLoginFailed": "Влизането в LDAP не бе успешно",
|
||||
"resetPasswordButton": "Нулиране на парола",
|
||||
"sendResetCode": "Изпрати код за нулиране",
|
||||
"resetCodeDesc": "Въведете потребителското си име, за да получите код за нулиране на паролата. Кодът ще бъде записан в лог файловете на Docker контейнера.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Общи",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "Доставчици на SSO",
|
||||
"ssoAddProvider": "Добавяне на доставчик",
|
||||
"ssoDocsLink": "Преглед на документи",
|
||||
"ssoProviderDocsLink": "Преглед на документи",
|
||||
"ssoNoProviders": "Няма конфигурирани доставчици на SSO.",
|
||||
"ssoProviderName": "Показвано име",
|
||||
"ssoProviderType": "Тип доставчик",
|
||||
"ssoDeleteProvider": "Изтриване на доставчик",
|
||||
"ssoDeleteConfirm": "Да се изтрие ли този доставчик? Свързаните потребители няма да могат да влязат.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Активирано",
|
||||
"ssoDisabled": "За хора с увреждания",
|
||||
"ssoSaveProvider": "Запазване на доставчика",
|
||||
"ssoTestConnection": "Тест на връзката",
|
||||
"ssoEditProvider": "Редактиране на доставчик",
|
||||
"ldapHost": "LDAP хост",
|
||||
"ldapPort": "Порт",
|
||||
"ldapUseTls": "Използвайте TLS (LDAPS)",
|
||||
"ldapBindDn": "Обвързване DN",
|
||||
"ldapBindPassword": "Свързване на парола",
|
||||
"ldapUserSearchBase": "База за търсене на потребители",
|
||||
"ldapUserSearchFilter": "Филтър за търсене на потребители",
|
||||
"ldapUsernameAttr": "Атрибут на потребителското име",
|
||||
"ldapDisplayNameAttr": "Атрибут за показвано име",
|
||||
"ldapGroupSearchBase": "База за групово търсене",
|
||||
"ldapAdminGroup": "Административна група",
|
||||
"ldapAllowedUsers": "Разрешени потребители",
|
||||
"sectionUsers": "Потребители",
|
||||
"sectionSessions": "Сесии",
|
||||
"sectionRoles": "Роли",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Преглед на документи",
|
||||
"tailscaleApiBaseUrl": "Базов URL адрес на API",
|
||||
"tailscaleApiBaseUrlDescription": "Незадължително. Насочете към самостоятелно хостван екземпляр на Headscale вместо api.tailscale.com. Оставете празно, за да използвате настройката по подразбиране на Tailscale.",
|
||||
"oidcDescription": "Конфигурирайте OpenID Connect за SSO. Полетата, маркирани с *, са задължителни.",
|
||||
"oidcDocsLink": "Преглед на документи",
|
||||
"oidcClientId": "Идентификационен номер на клиента",
|
||||
"oidcClientSecret": "Клиентска тайна",
|
||||
"oidcAuthUrl": "URL адрес за оторизация",
|
||||
"oidcIssuerUrl": "URL адрес на издателя",
|
||||
"oidcTokenUrl": "URL адрес на токена",
|
||||
"oidcUserIdentifier": "Път на потребителския идентификатор",
|
||||
"oidcDisplayName": "Път на показваното име",
|
||||
"oidcScopes": "Обхвати",
|
||||
"oidcUserinfoUrl": "Замяна на URL адреса на потребителската информация",
|
||||
"oidcAllowedUsers": "Разрешени потребители",
|
||||
"oidcAllowedUsersDesc": "По един имейл на ред. Оставете празно, за да разрешите всички.",
|
||||
"oidcAdminGroup": "Административна група",
|
||||
"oidcAdminGroupDesc": "Потребителите в тази група са с администраторски права. Оставете празно, за да деактивирате синхронизирането на групата.",
|
||||
"oidcGroupClaim": "Групово искане",
|
||||
"oidcGroupClaimDesc": "Незадължително. Пътят на твърдението, който съдържа групите на потребителя. По подразбиране е „групи“, „роли“, „група“. Използвайте това за доставчици с персонализирано твърдение (напр. Zitadel).",
|
||||
"oidcCaCert": "Персонализиран CA сертификат",
|
||||
"oidcCaCertDesc": "Незадължително. PEM-кодиран CA сертификат за OIDC доставчици, използващи частен или самоподписан CA. Оставете празно, за да използвате хранилището за доверени сертификати на системата.",
|
||||
"removeOidc": "Премахване",
|
||||
"usersCount": "{{count}} потребители",
|
||||
"searchUsers": "Търсене на потребители...",
|
||||
"noUsers": "Няма потребители",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Настройките на опашката са запазени",
|
||||
"tailscaleSettingsSaveFailed": "Запазването на настройките на Tailscale не бе успешно",
|
||||
"logLevelUpdateFailed": "Актуализирането на нивото на лога не бе успешно",
|
||||
"oidcSaved": "OIDC конфигурацията е запазена",
|
||||
"oidcSaveFailed": "Запазването на OIDC конфигурацията не бе успешно",
|
||||
"oidcRemoved": "OIDC конфигурацията е премахната",
|
||||
"oidcRemoveFailed": "Неуспешно премахване на OIDC конфигурацията",
|
||||
"createUserRequired": "Потребителско име и парола са задължителни",
|
||||
"createUserPasswordTooShort": "Паролата трябва да е поне 6 знака",
|
||||
"createUserSuccess": "Потребителят „{{username}}“ е създаден",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "ইলেকট্রন অ্যাপে এখনও বাহ্যিক প্রমাণীকরণ সমর্থিত নয়। OIDC লগইনের জন্য অনুগ্রহ করে ওয়েব সংস্করণ ব্যবহার করুন।",
|
||||
"loginWithProvider": "{{name}} দিয়ে লগইন করুন",
|
||||
"orContinueWith": "অথবা চালিয়ে যান",
|
||||
"ldapUsername": "LDAP ব্যবহারকারীর নাম",
|
||||
"ldapPassword": "এলডিএপি পাসওয়ার্ড",
|
||||
"ldapSignIn": "সাইন ইন করুন",
|
||||
"ldapLoginFailed": "LDAP লগইন ব্যর্থ হয়েছে",
|
||||
"resetPasswordButton": "পাসওয়ার্ড রিসেট করুন",
|
||||
"sendResetCode": "রিসেট কোড পাঠান",
|
||||
"resetCodeDesc": "পাসওয়ার্ড রিসেট কোড পেতে আপনার ইউজারনেম লিখুন। কোডটি ডকার কন্টেইনার লগে নথিভুক্ত করা হবে।",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "সাধারণ",
|
||||
"sectionOidc": "ওআইডিসি",
|
||||
"sectionSso": "এসএসও প্রদানকারী",
|
||||
"ssoAddProvider": "প্রদানকারী যোগ করুন",
|
||||
"ssoDocsLink": "নথি দেখুন",
|
||||
"ssoProviderDocsLink": "নথি দেখুন",
|
||||
"ssoNoProviders": "কোনো SSO প্রদানকারী কনফিগার করা নেই।",
|
||||
"ssoProviderName": "প্রদর্শনের নাম",
|
||||
"ssoProviderType": "প্রদানকারীর ধরণ",
|
||||
"ssoDeleteProvider": "প্রোভাইডার মুছুন",
|
||||
"ssoDeleteConfirm": "এই প্রোভাইডারটি মুছে ফেললে সংশ্লিষ্ট ব্যবহারকারীরা লগইন করতে পারবেন না।",
|
||||
"ssoTypeOidc": "ওআইডিসি",
|
||||
"ssoTypeLdap": "এলডিএপি",
|
||||
"ssoTypeGithub": "গিটহাব",
|
||||
"ssoTypeGoogle": "গুগল",
|
||||
"ssoEnabled": "সক্ষম করা হয়েছে",
|
||||
"ssoDisabled": "প্রতিবন্ধী",
|
||||
"ssoSaveProvider": "সরবরাহকারীকে সংরক্ষণ করুন",
|
||||
"ssoTestConnection": "সংযোগ পরীক্ষা করুন",
|
||||
"ssoEditProvider": "সম্পাদনা প্রদানকারী",
|
||||
"ldapHost": "এলডিএপি হোস্ট",
|
||||
"ldapPort": "বন্দর",
|
||||
"ldapUseTls": "TLS (LDAPS) ব্যবহার করুন",
|
||||
"ldapBindDn": "ডিএনকে আবদ্ধ করুন",
|
||||
"ldapBindPassword": "পাসওয়ার্ড সংযুক্ত করুন",
|
||||
"ldapUserSearchBase": "ব্যবহারকারী অনুসন্ধান ভিত্তি",
|
||||
"ldapUserSearchFilter": "ব্যবহারকারী অনুসন্ধান ফিল্টার",
|
||||
"ldapUsernameAttr": "ব্যবহারকারীর নাম অ্যাট্রিবিউট",
|
||||
"ldapDisplayNameAttr": "ডিসপ্লে নেম অ্যাট্রিবিউট",
|
||||
"ldapGroupSearchBase": "গ্রুপ অনুসন্ধান ভিত্তি",
|
||||
"ldapAdminGroup": "অ্যাডমিন গ্রুপ",
|
||||
"ldapAllowedUsers": "অনুমোদিত ব্যবহারকারী",
|
||||
"sectionUsers": "ব্যবহারকারীরা",
|
||||
"sectionSessions": "অধিবেশন",
|
||||
"sectionRoles": "ভূমিকা",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "নথি দেখুন",
|
||||
"tailscaleApiBaseUrl": "এপিআই বেস ইউআরএল",
|
||||
"tailscaleApiBaseUrlDescription": "ঐচ্ছিক। api.tailscale.com-এর পরিবর্তে একটি সেলফ-হোস্টেড হেডস্কেল ইনস্ট্যান্স নির্দেশ করুন। টেইলস্কেলের ডিফল্ট ব্যবহার করতে এটি খালি রাখুন।",
|
||||
"oidcDescription": "SSO-এর জন্য OpenID Connect কনফিগার করুন। * চিহ্নিত ফিল্ডগুলো পূরণ করা আবশ্যক।",
|
||||
"oidcDocsLink": "নথি দেখুন",
|
||||
"oidcClientId": "ক্লায়েন্ট আইডি",
|
||||
"oidcClientSecret": "ক্লায়েন্ট গোপনীয়",
|
||||
"oidcAuthUrl": "অনুমোদন ইউআরএল",
|
||||
"oidcIssuerUrl": "ইস্যুকারী ইউআরএল",
|
||||
"oidcTokenUrl": "টোকেন ইউআরএল",
|
||||
"oidcUserIdentifier": "ব্যবহারকারী শনাক্তকারী পথ",
|
||||
"oidcDisplayName": "ডিসপ্লে নেম পাথ",
|
||||
"oidcScopes": "স্কোপ",
|
||||
"oidcUserinfoUrl": "ব্যবহারকারীর তথ্য ইউআরএল ওভাররাইড করুন",
|
||||
"oidcAllowedUsers": "অনুমোদিত ব্যবহারকারী",
|
||||
"oidcAllowedUsersDesc": "প্রতি লাইনে একটি ইমেল। সবগুলো দেখানোর জন্য খালি রাখুন।",
|
||||
"oidcAdminGroup": "অ্যাডমিন গ্রুপ",
|
||||
"oidcAdminGroupDesc": "এই গ্রুপের ব্যবহারকারীদের অ্যাডমিন অ্যাক্সেস দেওয়া হয়েছে। গ্রুপ সিঙ্ক নিষ্ক্রিয় করতে এটি খালি রাখুন।",
|
||||
"oidcGroupClaim": "গোষ্ঠী দাবি",
|
||||
"oidcGroupClaimDesc": "ঐচ্ছিক। এটি হলো সেই ক্লেইম পাথ যেখানে ব্যবহারকারীর গ্রুপগুলো থাকে। ডিফল্ট হিসেবে প্রথমে গ্রুপ, তারপর রোল, এবং সবশেষে গ্রুপ থাকে। কাস্টম ক্লেইম আছে এমন প্রোভাইডারদের (যেমন জিটাডেল) জন্য এটি ব্যবহার করুন।",
|
||||
"oidcCaCert": "কাস্টম সিএ সার্টিফিকেট",
|
||||
"oidcCaCertDesc": "ঐচ্ছিক। প্রাইভেট বা সেলফ-সাইন্ড CA ব্যবহারকারী OIDC প্রোভাইডারদের জন্য PEM-এনকোডেড CA সার্টিফিকেট। সিস্টেম ট্রাস্ট স্টোর ব্যবহার করতে এটি খালি রাখুন।",
|
||||
"removeOidc": "অপসারণ করুন",
|
||||
"usersCount": "{{count}} ব্যবহারকারীরা",
|
||||
"searchUsers": "ব্যবহারকারীদের অনুসন্ধান করুন...",
|
||||
"noUsers": "কোন ব্যবহারকারী নেই",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "টেইলস্কেল সেটিংস সংরক্ষিত হয়েছে",
|
||||
"tailscaleSettingsSaveFailed": "টেইলস্কেল সেটিংস সংরক্ষণ করতে ব্যর্থ হয়েছে",
|
||||
"logLevelUpdateFailed": "লগ স্তর আপডেট করতে ব্যর্থ হয়েছে",
|
||||
"oidcSaved": "OIDC কনফিগারেশন সংরক্ষিত হয়েছে",
|
||||
"oidcSaveFailed": "OIDC কনফিগারেশন সংরক্ষণ করতে ব্যর্থ হয়েছে",
|
||||
"oidcRemoved": "OIDC কনফিগারেশন সরানো হয়েছে",
|
||||
"oidcRemoveFailed": "OIDC কনফিগারেশন অপসারণ করতে ব্যর্থ হয়েছে",
|
||||
"createUserRequired": "ব্যবহারকারীর নাম এবং পাসওয়ার্ড প্রয়োজন",
|
||||
"createUserPasswordTooShort": "পাসওয়ার্ডটি কমপক্ষে ৬ অক্ষরের হতে হবে।",
|
||||
"createUserSuccess": "ব্যবহারকারী \"{{username}}\" তৈরি করেছেন",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "L'autenticació externa encara no és compatible amb l'aplicació Electron. Si us plau, utilitzeu la versió web per iniciar sessió a OIDC.",
|
||||
"loginWithProvider": "Inicia la sessió amb {{name}}",
|
||||
"orContinueWith": "o continuar amb",
|
||||
"ldapUsername": "Nom d'usuari LDAP",
|
||||
"ldapPassword": "Contrasenya LDAP",
|
||||
"ldapSignIn": "Inicia la sessió",
|
||||
"ldapLoginFailed": "L'inici de sessió LDAP ha fallat",
|
||||
"resetPasswordButton": "Restablir contrasenya",
|
||||
"sendResetCode": "Enviar codi de restabliment",
|
||||
"resetCodeDesc": "Introdueix el teu nom d'usuari per rebre un codi de restabliment de contrasenya. El codi es registrarà als registres del contenidor Docker.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "General",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "Proveïdors d'SSO",
|
||||
"ssoAddProvider": "Afegeix un proveïdor",
|
||||
"ssoDocsLink": "Veure documents",
|
||||
"ssoProviderDocsLink": "Veure documents",
|
||||
"ssoNoProviders": "No hi ha cap proveïdor d'SSO configurat.",
|
||||
"ssoProviderName": "Nom de visualització",
|
||||
"ssoProviderType": "Tipus de proveïdor",
|
||||
"ssoDeleteProvider": "Suprimeix el proveïdor",
|
||||
"ssoDeleteConfirm": "Voleu suprimir aquest proveïdor? Els usuaris associats no podran iniciar la sessió.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Activat",
|
||||
"ssoDisabled": "Discapacitat",
|
||||
"ssoSaveProvider": "Desa el proveïdor",
|
||||
"ssoTestConnection": "Prova de connexió",
|
||||
"ssoEditProvider": "Edita el proveïdor",
|
||||
"ldapHost": "Amfitrió LDAP",
|
||||
"ldapPort": "Port",
|
||||
"ldapUseTls": "Utilitza TLS (LDAPS)",
|
||||
"ldapBindDn": "Vinculació de nom de domini",
|
||||
"ldapBindPassword": "Contrasenya de vinculació",
|
||||
"ldapUserSearchBase": "Base de cerca d'usuaris",
|
||||
"ldapUserSearchFilter": "Filtre de cerca d'usuaris",
|
||||
"ldapUsernameAttr": "Atribut de nom d'usuari",
|
||||
"ldapDisplayNameAttr": "Atribut del nom de visualització",
|
||||
"ldapGroupSearchBase": "Base de cerca de grups",
|
||||
"ldapAdminGroup": "Grup d'administradors",
|
||||
"ldapAllowedUsers": "Usuaris permesos",
|
||||
"sectionUsers": "Usuaris",
|
||||
"sectionSessions": "Sessions",
|
||||
"sectionRoles": "Rols",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Veure documents",
|
||||
"tailscaleApiBaseUrl": "URL base de l'API",
|
||||
"tailscaleApiBaseUrlDescription": "Opcional. Apunta a una instància de Headscale autoallotjada en comptes d'api.tailscale.com. Deixa el camp en blanc per utilitzar el valor predeterminat de Tailscale.",
|
||||
"oidcDescription": "Configura OpenID Connect per a SSO. Els camps marcats amb un * són obligatoris.",
|
||||
"oidcDocsLink": "Veure documents",
|
||||
"oidcClientId": "ID de client",
|
||||
"oidcClientSecret": "Secret del client",
|
||||
"oidcAuthUrl": "URL d'autorització",
|
||||
"oidcIssuerUrl": "URL de l'emissor",
|
||||
"oidcTokenUrl": "URL del testimoni",
|
||||
"oidcUserIdentifier": "Ruta d'identificació d'usuari",
|
||||
"oidcDisplayName": "Camí del nom de visualització",
|
||||
"oidcScopes": "Àmbits",
|
||||
"oidcUserinfoUrl": "Substitueix l'URL d'informació de l'usuari",
|
||||
"oidcAllowedUsers": "Usuaris permesos",
|
||||
"oidcAllowedUsersDesc": "Un correu electrònic per línia. Deixeu-ho en blanc per permetre-ho tot.",
|
||||
"oidcAdminGroup": "Grup d'administradors",
|
||||
"oidcAdminGroupDesc": "Els usuaris d'aquest grup tenen dret d'administrador. Deixeu-ho en blanc per desactivar la sincronització del grup.",
|
||||
"oidcGroupClaim": "Reclamació de grup",
|
||||
"oidcGroupClaimDesc": "Opcional. La ruta de reclamació que conté els grups de l'usuari. Per defecte, s'utilitzen grups, rols i, a continuació, grup. Feu servir això per a proveïdors amb una reclamació personalitzada (per exemple, Zitadel).",
|
||||
"oidcCaCert": "Certificat CA personalitzat",
|
||||
"oidcCaCertDesc": "Opcional. Certificat CA codificat amb PEM per a proveïdors OIDC que utilitzen una CA privada o autosignada. Deixeu-ho en blanc per utilitzar el magatzem de confiança del sistema.",
|
||||
"removeOidc": "Elimina",
|
||||
"usersCount": "{{count}} usuaris",
|
||||
"searchUsers": "Cerca usuaris...",
|
||||
"noUsers": "Cap usuari",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Configuració de la cua d'escala desada",
|
||||
"tailscaleSettingsSaveFailed": "No s'ha pogut desar la configuració de Tailscale",
|
||||
"logLevelUpdateFailed": "No s'ha pogut actualitzar el nivell de registre",
|
||||
"oidcSaved": "Configuració OIDC desada",
|
||||
"oidcSaveFailed": "No s'ha pogut desar la configuració OIDC.",
|
||||
"oidcRemoved": "Configuració OIDC eliminada",
|
||||
"oidcRemoveFailed": "No s'ha pogut eliminar la configuració OIDC.",
|
||||
"createUserRequired": "Cal un nom d'usuari i una contrasenya",
|
||||
"createUserPasswordTooShort": "La contrasenya ha de tenir com a mínim 6 caràcters",
|
||||
"createUserSuccess": "L'usuari \"{{username}}\" ha estat creat",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Externí ověřování zatím není v aplikaci Electron podporováno. Pro přihlášení k OIDC použijte prosím webovou verzi.",
|
||||
"loginWithProvider": "Přihlaste se pomocí {{name}}",
|
||||
"orContinueWith": "nebo pokračujte s",
|
||||
"ldapUsername": "Uživatelské jméno LDAP",
|
||||
"ldapPassword": "Heslo LDAP",
|
||||
"ldapSignIn": "Přihlásit se",
|
||||
"ldapLoginFailed": "Přihlášení k LDAP se nezdařilo",
|
||||
"resetPasswordButton": "Obnovit heslo",
|
||||
"sendResetCode": "Odeslat resetovací kód",
|
||||
"resetCodeDesc": "Zadejte své uživatelské jméno a obdržíte kód pro resetování hesla. Kód bude zaznamenán do protokolů kontejneru Docker.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Generál",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "Poskytovatelé jednotného přihlašování (SSO)",
|
||||
"ssoAddProvider": "Přidat poskytovatele",
|
||||
"ssoDocsLink": "Zobrazit dokumenty",
|
||||
"ssoProviderDocsLink": "Zobrazit dokumenty",
|
||||
"ssoNoProviders": "Nejsou nakonfigurováni žádní poskytovatelé jednotného přihlašování (SSO).",
|
||||
"ssoProviderName": "Zobrazovaný název",
|
||||
"ssoProviderType": "Typ poskytovatele",
|
||||
"ssoDeleteProvider": "Smazat poskytovatele",
|
||||
"ssoDeleteConfirm": "Smazat tohoto poskytovatele? Přidružení uživatelé se nebudou moci přihlásit.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Povoleno",
|
||||
"ssoDisabled": "Zakázáno",
|
||||
"ssoSaveProvider": "Uložit poskytovatele",
|
||||
"ssoTestConnection": "Testovací připojení",
|
||||
"ssoEditProvider": "Upravit poskytovatele",
|
||||
"ldapHost": "Hostitel LDAP",
|
||||
"ldapPort": "Přístav",
|
||||
"ldapUseTls": "Používejte TLS (LDAPS)",
|
||||
"ldapBindDn": "Vázat DN",
|
||||
"ldapBindPassword": "Vázat heslo",
|
||||
"ldapUserSearchBase": "Vyhledávací báze uživatelů",
|
||||
"ldapUserSearchFilter": "Filtr vyhledávání uživatelů",
|
||||
"ldapUsernameAttr": "Atribut uživatelského jména",
|
||||
"ldapDisplayNameAttr": "Atribut zobrazovaného názvu",
|
||||
"ldapGroupSearchBase": "Základna pro vyhledávání skupin",
|
||||
"ldapAdminGroup": "Skupina administrátorů",
|
||||
"ldapAllowedUsers": "Povolení uživatelé",
|
||||
"sectionUsers": "Uživatelé",
|
||||
"sectionSessions": "Relace",
|
||||
"sectionRoles": "Role",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Zobrazit dokumenty",
|
||||
"tailscaleApiBaseUrl": "Základní URL API",
|
||||
"tailscaleApiBaseUrlDescription": "Volitelné. Místo api.tailscale.com odkazujte na samoobslužnou instanci Headscale. Pro použití výchozí hodnoty Tailscale ponechte pole prázdné.",
|
||||
"oidcDescription": "Nakonfigurujte OpenID Connect pro SSO. Pole označená * jsou povinná.",
|
||||
"oidcDocsLink": "Zobrazit dokumenty",
|
||||
"oidcClientId": "ID klienta",
|
||||
"oidcClientSecret": "Tajemství klienta",
|
||||
"oidcAuthUrl": "Autorizační URL",
|
||||
"oidcIssuerUrl": "URL vydavatele",
|
||||
"oidcTokenUrl": "URL tokenu",
|
||||
"oidcUserIdentifier": "Cesta k identifikátoru uživatele",
|
||||
"oidcDisplayName": "Cesta k zobrazovanému názvu",
|
||||
"oidcScopes": "Rozsahy",
|
||||
"oidcUserinfoUrl": "Přepsat URL s uživatelskými informacemi",
|
||||
"oidcAllowedUsers": "Povolení uživatelé",
|
||||
"oidcAllowedUsersDesc": "Jeden e-mail na řádek. Pro povolení všech ponechte prázdné.",
|
||||
"oidcAdminGroup": "Skupina administrátorů",
|
||||
"oidcAdminGroupDesc": "Uživatelé v této skupině mají oprávnění k administraci. Pro deaktivaci synchronizace skupiny ponechte pole prázdné.",
|
||||
"oidcGroupClaim": "Skupinový nárok",
|
||||
"oidcGroupClaimDesc": "Volitelné. Cesta k deklaraci identity, která obsahuje skupiny uživatele. Výchozí nastavení je skupiny, role a poté skupina. Použijte pro poskytovatele s vlastní deklarací identity (např. Zitadel).",
|
||||
"oidcCaCert": "Vlastní certifikát certifikační autority",
|
||||
"oidcCaCertDesc": "Volitelné. Certifikát certifikační autority s kódováním PEM pro poskytovatele OIDC používající soukromou nebo samopodepsanou certifikační autoritu. Pro použití systémového úložiště důvěryhodných certifikátů ponechte pole prázdné.",
|
||||
"removeOidc": "Odstranit",
|
||||
"usersCount": "{{count}} uživatelů",
|
||||
"searchUsers": "Hledat uživatele...",
|
||||
"noUsers": "Žádní uživatelé",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Nastavení koncové stupnice uloženo",
|
||||
"tailscaleSettingsSaveFailed": "Nepodařilo se uložit nastavení Tailscale",
|
||||
"logLevelUpdateFailed": "Nepodařilo se aktualizovat úroveň protokolu",
|
||||
"oidcSaved": "Konfigurace OIDC uložena",
|
||||
"oidcSaveFailed": "Nepodařilo se uložit konfiguraci OIDC",
|
||||
"oidcRemoved": "Konfigurace OIDC odstraněna",
|
||||
"oidcRemoveFailed": "Nepodařilo se odebrat konfiguraci OIDC",
|
||||
"createUserRequired": "Uživatelské jméno a heslo jsou vyžadovány",
|
||||
"createUserPasswordTooShort": "Heslo musí mít alespoň 6 znaků",
|
||||
"createUserSuccess": "Uživatel „{{username}}“ vytvořil",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Ekstern godkendelse understøttes endnu ikke i Electron-appen. Brug webversionen til OIDC-login.",
|
||||
"loginWithProvider": "Log ind med {{name}}",
|
||||
"orContinueWith": "eller fortsæt med",
|
||||
"ldapUsername": "LDAP-brugernavn",
|
||||
"ldapPassword": "LDAP-adgangskode",
|
||||
"ldapSignIn": "Log ind",
|
||||
"ldapLoginFailed": "LDAP-login mislykkedes",
|
||||
"resetPasswordButton": "Nulstil adgangskode",
|
||||
"sendResetCode": "Send nulstillingskode",
|
||||
"resetCodeDesc": "Indtast dit brugernavn for at modtage en kode til nulstilling af adgangskode. Koden vil blive logget i docker-containerens logfiler.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Generel",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "SSO-udbydere",
|
||||
"ssoAddProvider": "Tilføj udbyder",
|
||||
"ssoDocsLink": "Se dokumenter",
|
||||
"ssoProviderDocsLink": "Se dokumenter",
|
||||
"ssoNoProviders": "Ingen SSO-udbydere er konfigureret.",
|
||||
"ssoProviderName": "Vist navn",
|
||||
"ssoProviderType": "Udbydertype",
|
||||
"ssoDeleteProvider": "Slet udbyder",
|
||||
"ssoDeleteConfirm": "Vil du slette denne udbyder? Tilknyttede brugere vil ikke kunne logge ind.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Aktiveret",
|
||||
"ssoDisabled": "Handicappet",
|
||||
"ssoSaveProvider": "Gem udbyder",
|
||||
"ssoTestConnection": "Testforbindelse",
|
||||
"ssoEditProvider": "Rediger udbyder",
|
||||
"ldapHost": "LDAP-vært",
|
||||
"ldapPort": "Havn",
|
||||
"ldapUseTls": "Brug TLS (LDAPS)",
|
||||
"ldapBindDn": "Bind DN",
|
||||
"ldapBindPassword": "Bind adgangskode",
|
||||
"ldapUserSearchBase": "Brugersøgningsbase",
|
||||
"ldapUserSearchFilter": "Brugersøgningsfilter",
|
||||
"ldapUsernameAttr": "Brugernavnsattribut",
|
||||
"ldapDisplayNameAttr": "Visningsnavnattribut",
|
||||
"ldapGroupSearchBase": "Gruppesøgningsbase",
|
||||
"ldapAdminGroup": "Admingruppe",
|
||||
"ldapAllowedUsers": "Tilladte brugere",
|
||||
"sectionUsers": "Brugere",
|
||||
"sectionSessions": "Sessioner",
|
||||
"sectionRoles": "Roller",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Se dokumenter",
|
||||
"tailscaleApiBaseUrl": "API-basis-URL",
|
||||
"tailscaleApiBaseUrlDescription": "Valgfrit. Peg på en selvhostet Headscale-instans i stedet for api.tailscale.com. Lad feltet stå tomt for at bruge standardindstillingen for Tailscale.",
|
||||
"oidcDescription": "Konfigurer OpenID Connect til SSO. Felter markeret med * er obligatoriske.",
|
||||
"oidcDocsLink": "Se dokumenter",
|
||||
"oidcClientId": "Klient-ID",
|
||||
"oidcClientSecret": "Klienthemmelighed",
|
||||
"oidcAuthUrl": "Autorisations-URL",
|
||||
"oidcIssuerUrl": "Udsteders URL",
|
||||
"oidcTokenUrl": "Token-URL",
|
||||
"oidcUserIdentifier": "Brugeridentifikatorsti",
|
||||
"oidcDisplayName": "Sti til visningsnavn",
|
||||
"oidcScopes": "Omfang",
|
||||
"oidcUserinfoUrl": "Tilsidesæt brugerinfo-URL",
|
||||
"oidcAllowedUsers": "Tilladte brugere",
|
||||
"oidcAllowedUsersDesc": "Én e-mail pr. linje. Lad feltet stå tomt for at tillade alle.",
|
||||
"oidcAdminGroup": "Admingruppe",
|
||||
"oidcAdminGroupDesc": "Brugere i denne gruppe har administratorrettigheder. Lad feltet stå tomt for at deaktivere gruppesynkronisering.",
|
||||
"oidcGroupClaim": "Gruppekrav",
|
||||
"oidcGroupClaimDesc": "Valgfrit. Kravsstien, der indeholder brugerens grupper. Standardindstillingen er grupper, roller og derefter gruppe. Brug dette til udbydere med et brugerdefineret krav (f.eks. Zitadel).",
|
||||
"oidcCaCert": "Brugerdefineret CA-certifikat",
|
||||
"oidcCaCertDesc": "Valgfrit. PEM-kodet CA-certifikat til OIDC-udbydere, der bruger en privat eller selvsigneret CA. Lad feltet stå tomt for at bruge systemets tillidslager.",
|
||||
"removeOidc": "Fjerne",
|
||||
"usersCount": "{{count}} brugere",
|
||||
"searchUsers": "Søg efter brugere...",
|
||||
"noUsers": "Ingen brugere",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Indstillinger for haleskala gemt",
|
||||
"tailscaleSettingsSaveFailed": "Kunne ikke gemme Tailscale-indstillinger",
|
||||
"logLevelUpdateFailed": "Kunne ikke opdatere logniveauet",
|
||||
"oidcSaved": "OIDC-konfiguration gemt",
|
||||
"oidcSaveFailed": "Kunne ikke gemme OIDC-konfigurationen",
|
||||
"oidcRemoved": "OIDC-konfiguration fjernet",
|
||||
"oidcRemoveFailed": "Kunne ikke fjerne OIDC-konfigurationen",
|
||||
"createUserRequired": "Brugernavn og adgangskode er påkrævet",
|
||||
"createUserPasswordTooShort": "Adgangskoden skal være mindst 6 tegn lang",
|
||||
"createUserSuccess": "Bruger \"{{username}}\" blev oprettet",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Externe Authentifizierung wird in der Electron-App derzeit nicht unterstützt. Bitte verwenden Sie die Web-Version für die OIDC-Anmeldung.",
|
||||
"loginWithProvider": "Anmeldung mit {{name}}",
|
||||
"orContinueWith": "oder fortfahren mit",
|
||||
"ldapUsername": "LDAP-Benutzername",
|
||||
"ldapPassword": "LDAP-Passwort",
|
||||
"ldapSignIn": "Anmelden",
|
||||
"ldapLoginFailed": "LDAP-Anmeldung fehlgeschlagen",
|
||||
"resetPasswordButton": "Passwort zurücksetzen",
|
||||
"sendResetCode": "Reset-Code senden",
|
||||
"resetCodeDesc": "Geben Sie Ihren Benutzernamen ein, um einen Code zum Zurücksetzen des Passworts zu erhalten. Der Code wird in den Docker-Container-Protokollen ausgegeben.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Allgemein",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "SSO-Anbieter",
|
||||
"ssoAddProvider": "Anbieter hinzufügen",
|
||||
"ssoDocsLink": "Dokumentation anzeigen",
|
||||
"ssoProviderDocsLink": "Dokumentation anzeigen",
|
||||
"ssoNoProviders": "Keine SSO-Anbieter konfiguriert.",
|
||||
"ssoProviderName": "Anzeigename",
|
||||
"ssoProviderType": "Anbietertyp",
|
||||
"ssoDeleteProvider": "Anbieter löschen",
|
||||
"ssoDeleteConfirm": "Diesen Anbieter löschen? Zugehörige Benutzer können sich nicht mehr anmelden.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Aktiviert",
|
||||
"ssoDisabled": "Deaktiviert",
|
||||
"ssoSaveProvider": "Anbieter speichern",
|
||||
"ssoTestConnection": "Verbindung testen",
|
||||
"ssoEditProvider": "Anbieter bearbeiten",
|
||||
"ldapHost": "LDAP-Host",
|
||||
"ldapPort": "Port",
|
||||
"ldapUseTls": "TLS verwenden (LDAPS)",
|
||||
"ldapBindDn": "Bind-DN",
|
||||
"ldapBindPassword": "Bind-Passwort",
|
||||
"ldapUserSearchBase": "Benutzer-Suchbasis",
|
||||
"ldapUserSearchFilter": "Benutzersuchfilter",
|
||||
"ldapUsernameAttr": "Benutzernamen-Attribut",
|
||||
"ldapDisplayNameAttr": "Anzeigename-Attribut",
|
||||
"ldapGroupSearchBase": "Gruppen-Suchbasis",
|
||||
"ldapAdminGroup": "Admin-Gruppe",
|
||||
"ldapAllowedUsers": "Erlaubte Benutzer",
|
||||
"sectionUsers": "Benutzer",
|
||||
"sectionSessions": "Sitzungen",
|
||||
"sectionRoles": "Rollen",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Dokumentation anzeigen",
|
||||
"tailscaleApiBaseUrl": "API-Basis-URL",
|
||||
"tailscaleApiBaseUrlDescription": "Optional. Verweisen Sie auf eine selbstgehostete Headscale-Instanz anstelle von api.tailscale.com. Lassen Sie das Feld leer, um die Tailscale-Standardeinstellung zu verwenden.",
|
||||
"oidcDescription": "OpenID Connect für SSO konfigurieren. Mit * gekennzeichnete Felder sind erforderlich.",
|
||||
"oidcDocsLink": "Dokumentation anzeigen",
|
||||
"oidcClientId": "Client-ID",
|
||||
"oidcClientSecret": "Client-Secret",
|
||||
"oidcAuthUrl": "Autorisierungs-URL",
|
||||
"oidcIssuerUrl": "Issuer-URL",
|
||||
"oidcTokenUrl": "Token-URL",
|
||||
"oidcUserIdentifier": "Benutzerkennungspfad",
|
||||
"oidcDisplayName": "Anzeigename-Pfad",
|
||||
"oidcScopes": "Scopes",
|
||||
"oidcUserinfoUrl": "Userinfo-URL überschreiben",
|
||||
"oidcAllowedUsers": "Zugelassene Benutzer",
|
||||
"oidcAllowedUsersDesc": "Eine E-Mail pro Zeile. Leer lassen, um alle zuzulassen.",
|
||||
"oidcAdminGroup": "Admin-Gruppe",
|
||||
"oidcAdminGroupDesc": "Benutzer in dieser Gruppe erhalten Admin-Berechtigungen. Leer lassen, um die Gruppensynchronisierung zu deaktivieren.",
|
||||
"oidcGroupClaim": "Gruppen-Claim",
|
||||
"oidcGroupClaimDesc": "Optional. Der Claim-Pfad, der die Gruppen des Benutzers enthält. Standardmäßig groups, roles, dann group. Verwenden Sie dies für Anbieter mit einem benutzerdefinierten Claim (z. B. Zitadel).",
|
||||
"oidcCaCert": "Benutzerdefiniertes CA-Zertifikat",
|
||||
"oidcCaCertDesc": "Optional. PEM-kodiertes CA-Zertifikat für OIDC-Anbieter, die eine private oder selbstsignierte Zertifizierungsstelle verwenden. Leer lassen, um den System-Vertrauensspeicher zu verwenden.",
|
||||
"removeOidc": "Entfernen",
|
||||
"usersCount": "{{count}} Benutzer",
|
||||
"searchUsers": "Benutzer suchen...",
|
||||
"noUsers": "Keine Benutzer",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Tailscale-Einstellungen gespeichert",
|
||||
"tailscaleSettingsSaveFailed": "Fehler beim Speichern der Tailscale-Einstellungen",
|
||||
"logLevelUpdateFailed": "Fehler beim Aktualisieren des Log-Levels",
|
||||
"oidcSaved": "OIDC-Konfiguration gespeichert",
|
||||
"oidcSaveFailed": "Fehler beim Speichern der OIDC-Konfiguration",
|
||||
"oidcRemoved": "OIDC-Konfiguration entfernt",
|
||||
"oidcRemoveFailed": "Fehler beim Entfernen der OIDC-Konfiguration",
|
||||
"createUserRequired": "Benutzername und Passwort sind erforderlich",
|
||||
"createUserPasswordTooShort": "Das Passwort muss mindestens 6 Zeichen lang sein",
|
||||
"createUserSuccess": "Benutzer \"{{username}}\" erstellt",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Ο εξωτερικός έλεγχος ταυτότητας δεν υποστηρίζεται ακόμη στην εφαρμογή Electron. Χρησιμοποιήστε την έκδοση ιστού για σύνδεση στο OIDC.",
|
||||
"loginWithProvider": "Σύνδεση με {{name}}",
|
||||
"orContinueWith": "ή συνεχίστε με",
|
||||
"ldapUsername": "Όνομα χρήστη LDAP",
|
||||
"ldapPassword": "Κωδικός πρόσβασης LDAP",
|
||||
"ldapSignIn": "Σύνδεση",
|
||||
"ldapLoginFailed": "Η σύνδεση LDAP απέτυχε",
|
||||
"resetPasswordButton": "Επαναφορά κωδικού πρόσβασης",
|
||||
"sendResetCode": "Αποστολή κωδικού επαναφοράς",
|
||||
"resetCodeDesc": "Εισαγάγετε το όνομα χρήστη σας για να λάβετε έναν κωδικό επαναφοράς κωδικού πρόσβασης. Ο κωδικός θα καταγραφεί στα αρχεία καταγραφής του κοντέινερ docker.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Γενικός",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "Πάροχοι SSO",
|
||||
"ssoAddProvider": "Προσθήκη παρόχου",
|
||||
"ssoDocsLink": "Προβολή εγγράφων",
|
||||
"ssoProviderDocsLink": "Προβολή εγγράφων",
|
||||
"ssoNoProviders": "Δεν έχουν ρυθμιστεί πάροχοι SSO.",
|
||||
"ssoProviderName": "Εμφανιζόμενο όνομα",
|
||||
"ssoProviderType": "Τύπος παρόχου",
|
||||
"ssoDeleteProvider": "Διαγραφή παρόχου",
|
||||
"ssoDeleteConfirm": "Διαγραφή αυτού του παρόχου; Οι συνδεδεμένοι χρήστες δεν θα μπορούν να συνδεθούν.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Ενεργοποιημένο",
|
||||
"ssoDisabled": "Ανάπηρος",
|
||||
"ssoSaveProvider": "Αποθήκευση παρόχου",
|
||||
"ssoTestConnection": "Δοκιμή σύνδεσης",
|
||||
"ssoEditProvider": "Επεξεργασία παρόχου",
|
||||
"ldapHost": "Κεντρικός υπολογιστής LDAP",
|
||||
"ldapPort": "Λιμάνι",
|
||||
"ldapUseTls": "Χρήση TLS (LDAPS)",
|
||||
"ldapBindDn": "Σύνδεση DN",
|
||||
"ldapBindPassword": "Σύνδεση κωδικού πρόσβασης",
|
||||
"ldapUserSearchBase": "Βάση αναζήτησης χρηστών",
|
||||
"ldapUserSearchFilter": "Φίλτρο αναζήτησης χρήστη",
|
||||
"ldapUsernameAttr": "Χαρακτηριστικό ονόματος χρήστη",
|
||||
"ldapDisplayNameAttr": "Χαρακτηριστικό ονόματος εμφάνισης",
|
||||
"ldapGroupSearchBase": "Βάση αναζήτησης ομάδας",
|
||||
"ldapAdminGroup": "Ομάδα Διαχειριστών",
|
||||
"ldapAllowedUsers": "Επιτρεπόμενοι χρήστες",
|
||||
"sectionUsers": "Χρήστες",
|
||||
"sectionSessions": "Συνεδρίες",
|
||||
"sectionRoles": "Ρόλοι",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Προβολή εγγράφων",
|
||||
"tailscaleApiBaseUrl": "URL βάσης API",
|
||||
"tailscaleApiBaseUrlDescription": "Προαιρετικό. Υποδείξτε μια αυτοφιλοξενούμενη παρουσία Headscale αντί για api.tailscale.com. Αφήστε το κενό για να χρησιμοποιήσετε την προεπιλογή Tailscale.",
|
||||
"oidcDescription": "Ρύθμιση παραμέτρων OpenID Connect για SSO. Τα πεδία που σημειώνονται με * είναι υποχρεωτικά.",
|
||||
"oidcDocsLink": "Προβολή εγγράφων",
|
||||
"oidcClientId": "Αναγνωριστικό πελάτη",
|
||||
"oidcClientSecret": "Μυστικό Πελάτη",
|
||||
"oidcAuthUrl": "URL εξουσιοδότησης",
|
||||
"oidcIssuerUrl": "URL εκδότη",
|
||||
"oidcTokenUrl": "URL διακριτικού",
|
||||
"oidcUserIdentifier": "Διαδρομή αναγνωριστικού χρήστη",
|
||||
"oidcDisplayName": "Διαδρομή ονόματος εμφάνισης",
|
||||
"oidcScopes": "Πεδία",
|
||||
"oidcUserinfoUrl": "Παράκαμψη URL πληροφοριών χρήστη",
|
||||
"oidcAllowedUsers": "Επιτρεπόμενοι χρήστες",
|
||||
"oidcAllowedUsersDesc": "Ένα email ανά γραμμή. Αφήστε το κενό για να επιτρέπονται όλα.",
|
||||
"oidcAdminGroup": "Ομάδα Διαχειριστών",
|
||||
"oidcAdminGroupDesc": "Οι χρήστες σε αυτήν την ομάδα έχουν παραχωρηθεί ως διαχειριστές. Αφήστε το κενό για να απενεργοποιήσετε τον συγχρονισμό ομάδας.",
|
||||
"oidcGroupClaim": "Ομαδική αξίωση",
|
||||
"oidcGroupClaimDesc": "Προαιρετικό. Η διαδρομή αξίωσης που περιέχει τις ομάδες του χρήστη. Η προεπιλεγμένη τιμή είναι ομάδες, ρόλοι και στη συνέχεια ομάδα. Χρησιμοποιήστε αυτήν τη διαδρομή για παρόχους με προσαρμοσμένη αξίωση (π.χ. Zitadel).",
|
||||
"oidcCaCert": "Προσαρμοσμένο Πιστοποιητικό CA",
|
||||
"oidcCaCertDesc": "Προαιρετικό. Πιστοποιητικό CA με κωδικοποίηση PEM για παρόχους OIDC που χρησιμοποιούν ιδιωτική ή αυτο-υπογεγραμμένη αρχή έκδοσης πιστοποιητικών. Αφήστε το κενό για να χρησιμοποιήσετε τον χώρο αποθήκευσης αξιοπιστίας του συστήματος.",
|
||||
"removeOidc": "Αφαιρώ",
|
||||
"usersCount": "{{count}} χρήστες",
|
||||
"searchUsers": "Αναζήτηση χρηστών...",
|
||||
"noUsers": "Δεν υπάρχουν χρήστες",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Οι ρυθμίσεις κλίμακας ουράς αποθηκεύτηκαν",
|
||||
"tailscaleSettingsSaveFailed": "Αποτυχία αποθήκευσης ρυθμίσεων κλίμακας ουράς",
|
||||
"logLevelUpdateFailed": "Η ενημέρωση του επιπέδου καταγραφής απέτυχε",
|
||||
"oidcSaved": "Η διαμόρφωση OIDC αποθηκεύτηκε",
|
||||
"oidcSaveFailed": "Αποτυχία αποθήκευσης της διαμόρφωσης OIDC",
|
||||
"oidcRemoved": "Η διαμόρφωση OIDC καταργήθηκε.",
|
||||
"oidcRemoveFailed": "Αποτυχία κατάργησης της διαμόρφωσης OIDC",
|
||||
"createUserRequired": "Απαιτούνται όνομα χρήστη και κωδικός πρόσβασης",
|
||||
"createUserPasswordTooShort": "Ο κωδικός πρόσβασης πρέπει να έχει τουλάχιστον 6 χαρακτήρες",
|
||||
"createUserSuccess": "Ο χρήστης \"{{username}}\" δημιουργήθηκε",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "La autenticación externa aún no es compatible con la aplicación Electron. Utilice la versión web para iniciar sesión con OIDC.",
|
||||
"loginWithProvider": "Iniciar sesión con {{name}}",
|
||||
"orContinueWith": "o continuar con",
|
||||
"ldapUsername": "Nombre de usuario LDAP",
|
||||
"ldapPassword": "Contraseña LDAP",
|
||||
"ldapSignIn": "Iniciar sesión",
|
||||
"ldapLoginFailed": "Error de inicio de sesión LDAP",
|
||||
"resetPasswordButton": "Restablecer contraseña",
|
||||
"sendResetCode": "Enviar código de restablecimiento",
|
||||
"resetCodeDesc": "Introduzca su nombre de usuario para recibir un código de restablecimiento de contraseña. El código aparecerá en los registros del contenedor de Docker.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "General",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "Proveedores SSO",
|
||||
"ssoAddProvider": "Añadir proveedor",
|
||||
"ssoDocsLink": "Ver documentación",
|
||||
"ssoProviderDocsLink": "Ver documentación",
|
||||
"ssoNoProviders": "No hay proveedores SSO configurados.",
|
||||
"ssoProviderName": "Nombre visible",
|
||||
"ssoProviderType": "Tipo de proveedor",
|
||||
"ssoDeleteProvider": "Eliminar proveedor",
|
||||
"ssoDeleteConfirm": "¿Eliminar este proveedor? Los usuarios asociados no podrán iniciar sesión.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Activado",
|
||||
"ssoDisabled": "Desactivado",
|
||||
"ssoSaveProvider": "Guardar proveedor",
|
||||
"ssoTestConnection": "Probar conexión",
|
||||
"ssoEditProvider": "Editar proveedor",
|
||||
"ldapHost": "Host LDAP",
|
||||
"ldapPort": "Puerto",
|
||||
"ldapUseTls": "Usar TLS (LDAPS)",
|
||||
"ldapBindDn": "DN de enlace",
|
||||
"ldapBindPassword": "Contraseña de enlace",
|
||||
"ldapUserSearchBase": "Base de búsqueda de usuarios",
|
||||
"ldapUserSearchFilter": "Filtro de búsqueda de usuarios",
|
||||
"ldapUsernameAttr": "Atributo de nombre de usuario",
|
||||
"ldapDisplayNameAttr": "Atributo de nombre visible",
|
||||
"ldapGroupSearchBase": "Base de búsqueda de grupos",
|
||||
"ldapAdminGroup": "Grupo de administradores",
|
||||
"ldapAllowedUsers": "Usuarios permitidos",
|
||||
"sectionUsers": "Usuarios",
|
||||
"sectionSessions": "Sesiones",
|
||||
"sectionRoles": "Roles",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Ver documentación",
|
||||
"tailscaleApiBaseUrl": "URL base de la API",
|
||||
"tailscaleApiBaseUrlDescription": "Opcional. Apunta a una instancia de Headscale autohospedada en lugar de api.tailscale.com. Déjalo en blanco para usar la configuración predeterminada de Tailscale.",
|
||||
"oidcDescription": "Configurar OpenID Connect para inicio de sesión único (SSO). Los campos marcados con * son obligatorios.",
|
||||
"oidcDocsLink": "Ver documentación",
|
||||
"oidcClientId": "ID de cliente",
|
||||
"oidcClientSecret": "Secreto de cliente",
|
||||
"oidcAuthUrl": "URL de autorización",
|
||||
"oidcIssuerUrl": "URL del emisor",
|
||||
"oidcTokenUrl": "URL del token",
|
||||
"oidcUserIdentifier": "Ruta del identificador de usuario",
|
||||
"oidcDisplayName": "Ruta del nombre para mostrar",
|
||||
"oidcScopes": "Ámbitos",
|
||||
"oidcUserinfoUrl": "Sobrescribir URL de Userinfo",
|
||||
"oidcAllowedUsers": "Usuarios permitidos",
|
||||
"oidcAllowedUsersDesc": "Un correo electrónico por línea. Déjelo vacío para permitir todos.",
|
||||
"oidcAdminGroup": "Grupo de administradores",
|
||||
"oidcAdminGroupDesc": "Los usuarios de este grupo reciben permisos de administrador. Déjelo vacío para desactivar la sincronización de grupos.",
|
||||
"oidcGroupClaim": "Claim de grupo",
|
||||
"oidcGroupClaimDesc": "Opcional. La ruta del claim que contiene los grupos del usuario. Por defecto, se usan 'groups', 'roles' y luego 'group'. Úselo para proveedores con un claim personalizado (p. ej. Zitadel).",
|
||||
"oidcCaCert": "Certificado CA personalizado",
|
||||
"oidcCaCertDesc": "Opcional. Certificado CA codificado en PEM para proveedores OIDC que utilicen una CA privada o autofirmada. Déjelo vacío para usar el almacén de confianza del sistema.",
|
||||
"removeOidc": "Eliminar",
|
||||
"usersCount": "{{count}} usuarios",
|
||||
"searchUsers": "Buscar usuarios...",
|
||||
"noUsers": "No hay usuarios",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Ajustes de Tailscale guardados",
|
||||
"tailscaleSettingsSaveFailed": "Error al guardar los ajustes de Tailscale",
|
||||
"logLevelUpdateFailed": "Error al actualizar el nivel de registro",
|
||||
"oidcSaved": "Configuración OIDC guardada",
|
||||
"oidcSaveFailed": "Error al guardar la configuración OIDC",
|
||||
"oidcRemoved": "Configuración OIDC eliminada",
|
||||
"oidcRemoveFailed": "Error al eliminar la configuración OIDC",
|
||||
"createUserRequired": "El nombre de usuario y la contraseña son obligatorios",
|
||||
"createUserPasswordTooShort": "La contraseña debe tener al menos 6 caracteres",
|
||||
"createUserSuccess": "Usuario \"{{username}}\" creado",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Ulkoista todennusta ei vielä tueta Electron-sovelluksessa. Käytä verkkoversiota OIDC-kirjautumiseen.",
|
||||
"loginWithProvider": "Kirjaudu sisään tunnuksella {{name}}",
|
||||
"orContinueWith": "tai jatka",
|
||||
"ldapUsername": "LDAP-käyttäjätunnus",
|
||||
"ldapPassword": "LDAP-salasana",
|
||||
"ldapSignIn": "Kirjaudu sisään",
|
||||
"ldapLoginFailed": "LDAP-kirjautuminen epäonnistui",
|
||||
"resetPasswordButton": "Palauta salasana",
|
||||
"sendResetCode": "Lähetä nollauskoodi",
|
||||
"resetCodeDesc": "Syötä käyttäjätunnuksesi saadaksesi salasanan palautuskoodin. Koodi kirjataan Docker-säilön lokeihin.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Yleistä",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "SSO-palveluntarjoajat",
|
||||
"ssoAddProvider": "Lisää palveluntarjoaja",
|
||||
"ssoDocsLink": "Näytä dokumentit",
|
||||
"ssoProviderDocsLink": "Näytä dokumentit",
|
||||
"ssoNoProviders": "SSO-palveluntarjoajia ei ole määritetty.",
|
||||
"ssoProviderName": "Näyttönimi",
|
||||
"ssoProviderType": "Palveluntarjoajan tyyppi",
|
||||
"ssoDeleteProvider": "Poista palveluntarjoaja",
|
||||
"ssoDeleteConfirm": "Poistetaanko tämä palveluntarjoaja? Liitännäiset käyttäjät eivät voi kirjautua sisään.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Käytössä",
|
||||
"ssoDisabled": "Liikuntarajoitteinen",
|
||||
"ssoSaveProvider": "Tallenna palveluntarjoaja",
|
||||
"ssoTestConnection": "Testaa yhteys",
|
||||
"ssoEditProvider": "Muokkaa palveluntarjoajaa",
|
||||
"ldapHost": "LDAP-isäntä",
|
||||
"ldapPort": "Portti",
|
||||
"ldapUseTls": "Käytä TLS:ää (LDAPS)",
|
||||
"ldapBindDn": "Sido DN",
|
||||
"ldapBindPassword": "Sido salasana",
|
||||
"ldapUserSearchBase": "Käyttäjähakupohja",
|
||||
"ldapUserSearchFilter": "Käyttäjän hakusuodatin",
|
||||
"ldapUsernameAttr": "Käyttäjätunnusattribuutti",
|
||||
"ldapDisplayNameAttr": "Näyttönimen attribuutti",
|
||||
"ldapGroupSearchBase": "Ryhmän hakukanta",
|
||||
"ldapAdminGroup": "Hallintaryhmä",
|
||||
"ldapAllowedUsers": "Sallitut käyttäjät",
|
||||
"sectionUsers": "Käyttäjät",
|
||||
"sectionSessions": "Istunnot",
|
||||
"sectionRoles": "Roolit",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Näytä dokumentit",
|
||||
"tailscaleApiBaseUrl": "API-perus-URL",
|
||||
"tailscaleApiBaseUrlDescription": "Valinnainen. Osoita itse isännöityyn Headscale-instanssiin api.tailscale.com-sivuston sijaan. Jätä tyhjäksi käyttääksesi Tailscalen oletusarvoa.",
|
||||
"oidcDescription": "Määritä OpenID Connect kertakirjautumista varten. Tähdellä (*) merkityt kentät ovat pakollisia.",
|
||||
"oidcDocsLink": "Näytä dokumentit",
|
||||
"oidcClientId": "Asiakastunnus",
|
||||
"oidcClientSecret": "Asiakkaan salaisuus",
|
||||
"oidcAuthUrl": "Valtuutus-URL-osoite",
|
||||
"oidcIssuerUrl": "Liikkeeseenlaskijan URL-osoite",
|
||||
"oidcTokenUrl": "Tunnuksen URL-osoite",
|
||||
"oidcUserIdentifier": "Käyttäjätunnisteen polku",
|
||||
"oidcDisplayName": "Näyttönimen polku",
|
||||
"oidcScopes": "Laajuusalueet",
|
||||
"oidcUserinfoUrl": "Käyttäjätietojen URL-osoitteen ohitus",
|
||||
"oidcAllowedUsers": "Sallitut käyttäjät",
|
||||
"oidcAllowedUsersDesc": "Yksi sähköpostiosoite riviä kohden. Jätä tyhjäksi salliaksesi kaikki.",
|
||||
"oidcAdminGroup": "Hallintaryhmä",
|
||||
"oidcAdminGroupDesc": "Tämän ryhmän käyttäjille on myönnetty järjestelmänvalvojan oikeudet. Jätä tyhjäksi, jos haluat poistaa ryhmän synkronoinnin käytöstä.",
|
||||
"oidcGroupClaim": "Ryhmävaatimus",
|
||||
"oidcGroupClaimDesc": "Valinnainen. Vaatimuspolku, joka sisältää käyttäjän ryhmät. Oletusarvo on ryhmät, roolit ja lopuksi ryhmä. Käytä tätä palveluntarjoajille, joilla on mukautettu vaatimus (esim. Zitadel).",
|
||||
"oidcCaCert": "Mukautettu CA-varmenne",
|
||||
"oidcCaCertDesc": "Valinnainen. PEM-koodattu CA-varmenne OIDC-palveluntarjoajille, jotka käyttävät yksityistä tai itse allekirjoitettua CA:ta. Jätä tyhjäksi, jos haluat käyttää järjestelmän luotettavuussäilöä.",
|
||||
"removeOidc": "Poistaa",
|
||||
"usersCount": "{{count}} käyttäjää",
|
||||
"searchUsers": "Hae käyttäjiä...",
|
||||
"noUsers": "Ei käyttäjiä",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Tailscale-asetukset tallennettu",
|
||||
"tailscaleSettingsSaveFailed": "Tailscale-asetusten tallentaminen epäonnistui",
|
||||
"logLevelUpdateFailed": "Lokitason päivittäminen epäonnistui",
|
||||
"oidcSaved": "OIDC-määritys tallennettu",
|
||||
"oidcSaveFailed": "OIDC-määrityksen tallentaminen epäonnistui",
|
||||
"oidcRemoved": "OIDC-määritys poistettu",
|
||||
"oidcRemoveFailed": "OIDC-määrityksen poistaminen epäonnistui",
|
||||
"createUserRequired": "Käyttäjätunnus ja salasana vaaditaan",
|
||||
"createUserPasswordTooShort": "Salasanan on oltava vähintään 6 merkkiä pitkä",
|
||||
"createUserSuccess": "Käyttäjä \"{{username}}\" luotu",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "L'authentification externe n'est pas encore prise en charge dans l'application Electron. Veuillez utiliser la version web pour la connexion OIDC.",
|
||||
"loginWithProvider": "Se connecter avec {{name}}",
|
||||
"orContinueWith": "ou continuer avec",
|
||||
"ldapUsername": "Nom d'utilisateur LDAP",
|
||||
"ldapPassword": "Mot de passe LDAP",
|
||||
"ldapSignIn": "Se connecter",
|
||||
"ldapLoginFailed": "Échec de la connexion LDAP",
|
||||
"resetPasswordButton": "Réinitialiser le mot de passe",
|
||||
"sendResetCode": "Envoyer le code de réinitialisation",
|
||||
"resetCodeDesc": "Saisissez votre nom d'utilisateur pour recevoir un code de réinitialisation du mot de passe. Le code sera enregistré dans les journaux du conteneur Docker.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Général",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "Fournisseurs SSO",
|
||||
"ssoAddProvider": "Ajouter un fournisseur",
|
||||
"ssoDocsLink": "Documentation",
|
||||
"ssoProviderDocsLink": "Documentation",
|
||||
"ssoNoProviders": "Aucun fournisseur SSO configuré.",
|
||||
"ssoProviderName": "Nom d’affichage",
|
||||
"ssoProviderType": "Type de fournisseur",
|
||||
"ssoDeleteProvider": "Supprimer le fournisseur",
|
||||
"ssoDeleteConfirm": "Supprimer ce fournisseur ? Les utilisateurs associés ne pourront plus se connecter.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Activé",
|
||||
"ssoDisabled": "Désactivé",
|
||||
"ssoSaveProvider": "Enregistrer le fournisseur",
|
||||
"ssoTestConnection": "Tester la connexion",
|
||||
"ssoEditProvider": "Modifier le fournisseur",
|
||||
"ldapHost": "Hôte LDAP",
|
||||
"ldapPort": "Port",
|
||||
"ldapUseTls": "Utiliser TLS (LDAPS)",
|
||||
"ldapBindDn": "DN de liaison",
|
||||
"ldapBindPassword": "Mot de passe de liaison",
|
||||
"ldapUserSearchBase": "Base de recherche utilisateur",
|
||||
"ldapUserSearchFilter": "Filtre de recherche utilisateur",
|
||||
"ldapUsernameAttr": "Attribut de nom d’utilisateur",
|
||||
"ldapDisplayNameAttr": "Attribut de nom d’affichage",
|
||||
"ldapGroupSearchBase": "Base de recherche de groupe",
|
||||
"ldapAdminGroup": "Groupe admin",
|
||||
"ldapAllowedUsers": "Utilisateurs autorisés",
|
||||
"sectionUsers": "Utilisateurs",
|
||||
"sectionSessions": "Sessions",
|
||||
"sectionRoles": "Rôles",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Voir la documentation",
|
||||
"tailscaleApiBaseUrl": "URL de base de l'API",
|
||||
"tailscaleApiBaseUrlDescription": "Facultatif. Indiquez une instance Headscale auto-hébergée au lieu de api.tailscale.com. Laissez ce champ vide pour utiliser la configuration par défaut de Tailscale.",
|
||||
"oidcDescription": "Configurer OpenID Connect pour l'authentification unique. Les champs marqués * sont obligatoires.",
|
||||
"oidcDocsLink": "Voir la documentation",
|
||||
"oidcClientId": "ID client",
|
||||
"oidcClientSecret": "Secret client",
|
||||
"oidcAuthUrl": "URL d'autorisation",
|
||||
"oidcIssuerUrl": "URL de l'émetteur",
|
||||
"oidcTokenUrl": "URL du jeton",
|
||||
"oidcUserIdentifier": "Chemin de l'identifiant utilisateur",
|
||||
"oidcDisplayName": "Chemin du nom affiché",
|
||||
"oidcScopes": "Scopes",
|
||||
"oidcUserinfoUrl": "URL Userinfo personnalisée",
|
||||
"oidcAllowedUsers": "Utilisateurs autorisés",
|
||||
"oidcAllowedUsersDesc": "Une adresse e-mail par ligne. Laissez vide pour autoriser tout le monde.",
|
||||
"oidcAdminGroup": "Groupe administrateur",
|
||||
"oidcAdminGroupDesc": "Les utilisateurs de ce groupe ont les droits administrateur. Laissez vide pour désactiver la synchronisation de groupe.",
|
||||
"oidcGroupClaim": "Revendication de groupe",
|
||||
"oidcGroupClaimDesc": "Facultatif. Chemin de la revendication contenant les groupes de l'utilisateur. Par défaut : groups, roles, puis group. Utilisez ce champ pour les fournisseurs avec une revendication personnalisée (ex. Zitadel).",
|
||||
"oidcCaCert": "Certificat CA personnalisé",
|
||||
"oidcCaCertDesc": "Facultatif. Certificat CA au format PEM pour les fournisseurs OIDC utilisant une CA privée ou auto-signée. Laissez vide pour utiliser le magasin de confiance du système.",
|
||||
"removeOidc": "Supprimer",
|
||||
"usersCount": "{{count}} utilisateurs",
|
||||
"searchUsers": "Rechercher des utilisateurs...",
|
||||
"noUsers": "Aucun utilisateur",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Paramètres Tailscale enregistrés",
|
||||
"tailscaleSettingsSaveFailed": "Échec de l'enregistrement des paramètres Tailscale",
|
||||
"logLevelUpdateFailed": "Échec de la mise à jour du niveau de journalisation",
|
||||
"oidcSaved": "Configuration OIDC enregistrée",
|
||||
"oidcSaveFailed": "Échec de l'enregistrement de la configuration OIDC",
|
||||
"oidcRemoved": "Configuration OIDC supprimée",
|
||||
"oidcRemoveFailed": "Échec de la suppression de la configuration OIDC",
|
||||
"createUserRequired": "Nom d'utilisateur et mot de passe requis",
|
||||
"createUserPasswordTooShort": "Le mot de passe doit comporter au moins 6 caractères",
|
||||
"createUserSuccess": "Utilisateur « {{username}} » créé",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "אימות חיצוני אינו נתמך עדיין באפליקציית Electron. אנא השתמש בגרסת האינטרנט לצורך כניסה ל-OIDC.",
|
||||
"loginWithProvider": "התחברות באמצעות {{name}}",
|
||||
"orContinueWith": "או להמשיך עם",
|
||||
"ldapUsername": "שם משתמש LDAP",
|
||||
"ldapPassword": "סיסמת LDAP",
|
||||
"ldapSignIn": "כניסה",
|
||||
"ldapLoginFailed": "כניסה ל-LDAP נכשלה",
|
||||
"resetPasswordButton": "איפוס סיסמה",
|
||||
"sendResetCode": "שלח קוד איפוס",
|
||||
"resetCodeDesc": "הזן את שם המשתמש שלך כדי לקבל קוד איפוס סיסמה. הקוד יירשם ביומני המכולה של docker.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "כְּלָלִי",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "ספקי SSO",
|
||||
"ssoAddProvider": "הוסף ספק",
|
||||
"ssoDocsLink": "הצג מסמכים",
|
||||
"ssoProviderDocsLink": "הצג מסמכים",
|
||||
"ssoNoProviders": "לא הוגדרו ספקי SSO.",
|
||||
"ssoProviderName": "שם תצוגה",
|
||||
"ssoProviderType": "סוג ספק",
|
||||
"ssoDeleteProvider": "מחיקת ספק",
|
||||
"ssoDeleteConfirm": "למחוק ספק זה? משתמשים משויכים לא יוכלו להתחבר.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "גיטהאב",
|
||||
"ssoTypeGoogle": "גוגל",
|
||||
"ssoEnabled": "מופעל",
|
||||
"ssoDisabled": "נָכֶה",
|
||||
"ssoSaveProvider": "שמור ספק",
|
||||
"ssoTestConnection": "בדיקת חיבור",
|
||||
"ssoEditProvider": "עריכת ספק",
|
||||
"ldapHost": "מארח LDAP",
|
||||
"ldapPort": "נָמָל",
|
||||
"ldapUseTls": "השתמש ב-TLS (LDAPS)",
|
||||
"ldapBindDn": "קשר DN",
|
||||
"ldapBindPassword": "קשירת סיסמה",
|
||||
"ldapUserSearchBase": "בסיס חיפוש משתמשים",
|
||||
"ldapUserSearchFilter": "מסנן חיפוש משתמשים",
|
||||
"ldapUsernameAttr": "מאפיין שם משתמש",
|
||||
"ldapDisplayNameAttr": "מאפיין שם תצוגה",
|
||||
"ldapGroupSearchBase": "בסיס חיפוש קבוצתי",
|
||||
"ldapAdminGroup": "קבוצת ניהול",
|
||||
"ldapAllowedUsers": "משתמשים מורשים",
|
||||
"sectionUsers": "משתמשים",
|
||||
"sectionSessions": "מפגשים",
|
||||
"sectionRoles": "תפקידים",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "הצג מסמכים",
|
||||
"tailscaleApiBaseUrl": "כתובת אתר בסיסית של ה-API",
|
||||
"tailscaleApiBaseUrlDescription": "אופציונלי. הצבע על מופע Headscale המתארח בעצמו במקום על api.tailscale.com. השאר ריק כדי להשתמש בברירת המחדל של Tailscale.",
|
||||
"oidcDescription": "הגדר את OpenID Connect עבור SSO. שדות המסומנים ב-* הם שדות חובה.",
|
||||
"oidcDocsLink": "הצג מסמכים",
|
||||
"oidcClientId": "מזהה לקוח",
|
||||
"oidcClientSecret": "סוד הלקוח",
|
||||
"oidcAuthUrl": "כתובת אתר להרשאה",
|
||||
"oidcIssuerUrl": "כתובת URL של המנפיק",
|
||||
"oidcTokenUrl": "כתובת אתר של אסימון",
|
||||
"oidcUserIdentifier": "נתיב מזהה המשתמש",
|
||||
"oidcDisplayName": "נתיב שם התצוגה",
|
||||
"oidcScopes": "טווחים",
|
||||
"oidcUserinfoUrl": "עקיפת כתובת URL של פרטי משתמש",
|
||||
"oidcAllowedUsers": "משתמשים מורשים",
|
||||
"oidcAllowedUsersDesc": "אימייל אחד בכל שורה. יש להשאיר ריק כדי לאפשר את כולם.",
|
||||
"oidcAdminGroup": "קבוצת ניהול",
|
||||
"oidcAdminGroupDesc": "למשתמשים בקבוצה זו יש הרשאה לניהול. יש להשאיר ריק כדי להשבית את סנכרון הקבוצה.",
|
||||
"oidcGroupClaim": "תביעה קבוצתית",
|
||||
"oidcGroupClaimDesc": "אופציונלי. נתיב התביעה המכיל את קבוצות המשתמש. ברירת המחדל היא קבוצות, תפקידים ולאחר מכן קבוצה. השתמש באפשרות זו עבור ספקים עם תביעה מותאמת אישית (למשל Zitadel).",
|
||||
"oidcCaCert": "תעודת CA מותאמת אישית",
|
||||
"oidcCaCertDesc": "אופציונלי. אישור CA מקודד PEM עבור ספקי OIDC המשתמשים באישור CA פרטי או חתום עצמית. השאר ריק כדי להשתמש במאגר האמון של המערכת.",
|
||||
"removeOidc": "לְהַסִיר",
|
||||
"usersCount": "{{count}} משתמשים",
|
||||
"searchUsers": "חיפוש משתמשים...",
|
||||
"noUsers": "אין משתמשים",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "הגדרות זנב-סולם נשמרו",
|
||||
"tailscaleSettingsSaveFailed": "שמירת הגדרות Tailscale נכשלה",
|
||||
"logLevelUpdateFailed": "נכשל עדכון רמת היומן",
|
||||
"oidcSaved": "תצורת OIDC נשמרה",
|
||||
"oidcSaveFailed": "שמירת תצורת OIDC נכשלה",
|
||||
"oidcRemoved": "תצורת OIDC הוסרה",
|
||||
"oidcRemoveFailed": "הסרת תצורת OIDC נכשלה",
|
||||
"createUserRequired": "נדרשים שם משתמש וסיסמה",
|
||||
"createUserPasswordTooShort": "הסיסמה חייבת להיות באורך של לפחות 6 תווים",
|
||||
"createUserSuccess": "משתמש \"{{username}}\" נוצר",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Electron ऐप में अभी तक बाहरी प्रमाणीकरण समर्थित नहीं है। OIDC लॉगिन के लिए कृपया वेब संस्करण का उपयोग करें।",
|
||||
"loginWithProvider": "{{name}} से लॉगिन करें",
|
||||
"orContinueWith": "या इसके साथ जारी रखें",
|
||||
"ldapUsername": "LDAP उपयोगकर्ता नाम",
|
||||
"ldapPassword": "LDAP पासवर्ड",
|
||||
"ldapSignIn": "साइन इन करें",
|
||||
"ldapLoginFailed": "LDAP लॉगिन विफल",
|
||||
"resetPasswordButton": "पासवर्ड रीसेट करें",
|
||||
"sendResetCode": "रीसेट कोड भेजें",
|
||||
"resetCodeDesc": "पासवर्ड रीसेट कोड प्राप्त करने के लिए अपना उपयोगकर्ता नाम दर्ज करें। कोड Docker कंटेनर लॉग में लॉग किया जाएगा।",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "सामान्य",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "SSO प्रदाता",
|
||||
"ssoAddProvider": "प्रदाता जोड़ें",
|
||||
"ssoDocsLink": "दस्तावेज़ देखें",
|
||||
"ssoProviderDocsLink": "दस्तावेज़ देखें",
|
||||
"ssoNoProviders": "कोई SSO प्रदाता कॉन्फ़िगर नहीं है।",
|
||||
"ssoProviderName": "प्रदर्शन नाम",
|
||||
"ssoProviderType": "प्रदाता प्रकार",
|
||||
"ssoDeleteProvider": "प्रदाता हटाएँ",
|
||||
"ssoDeleteConfirm": "इस प्रदाता को हटाएँ? इससे जुड़े उपयोगकर्ता लॉगिन नहीं कर पाएंगे।",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "सक्षम",
|
||||
"ssoDisabled": "अक्षम",
|
||||
"ssoSaveProvider": "प्रदाता सहेजें",
|
||||
"ssoTestConnection": "कनेक्शन परीक्षण",
|
||||
"ssoEditProvider": "प्रदाता संपादित करें",
|
||||
"ldapHost": "LDAP होस्ट",
|
||||
"ldapPort": "पोर्ट",
|
||||
"ldapUseTls": "TLS (LDAPS) का उपयोग करें",
|
||||
"ldapBindDn": "Bind DN",
|
||||
"ldapBindPassword": "Bind पासवर्ड",
|
||||
"ldapUserSearchBase": "उपयोगकर्ता खोज आधार",
|
||||
"ldapUserSearchFilter": "उपयोगकर्ता खोज फ़िल्टर",
|
||||
"ldapUsernameAttr": "उपयोगकर्ता नाम विशेषता",
|
||||
"ldapDisplayNameAttr": "प्रदर्शन नाम विशेषता",
|
||||
"ldapGroupSearchBase": "समूह खोज आधार",
|
||||
"ldapAdminGroup": "एडमिन समूह",
|
||||
"ldapAllowedUsers": "अनुमत उपयोगकर्ता",
|
||||
"sectionUsers": "उपयोगकर्ता",
|
||||
"sectionSessions": "सत्र",
|
||||
"sectionRoles": "भूमिकाएँ",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "दस्तावेज़ देखें",
|
||||
"tailscaleApiBaseUrl": "एपीआई बेस यूआरएल",
|
||||
"tailscaleApiBaseUrlDescription": "यह विकल्प वैकल्पिक है। api.tailscale.com के बजाय किसी स्व-होस्टेड हेडस्केल इंस्टेंस को इंगित करें। डिफ़ॉल्ट टेलस्केल इंस्टेंस का उपयोग करने के लिए इसे खाली छोड़ दें।",
|
||||
"oidcDescription": "SSO के लिए OpenID Connect कॉन्फ़िगर करें। * से चिह्नित फ़ील्ड आवश्यक हैं।",
|
||||
"oidcDocsLink": "दस्तावेज़ देखें",
|
||||
"oidcClientId": "क्लाइंट आईडी",
|
||||
"oidcClientSecret": "क्लाइंट सीक्रेट",
|
||||
"oidcAuthUrl": "प्राधिकरण URL",
|
||||
"oidcIssuerUrl": "जारीकर्ता URL",
|
||||
"oidcTokenUrl": "टोकन URL",
|
||||
"oidcUserIdentifier": "उपयोगकर्ता पहचानकर्ता पथ",
|
||||
"oidcDisplayName": "प्रदर्शन नाम पथ",
|
||||
"oidcScopes": "स्कोप",
|
||||
"oidcUserinfoUrl": "Userinfo URL ओवरराइड करें",
|
||||
"oidcAllowedUsers": "अनुमत उपयोगकर्ता",
|
||||
"oidcAllowedUsersDesc": "प्रति पंक्ति एक ईमेल। सभी को अनुमति देने के लिए खाली छोड़ें।",
|
||||
"oidcAdminGroup": "व्यवस्थापक समूह",
|
||||
"oidcAdminGroupDesc": "इस समूह के उपयोगकर्ताओं को व्यवस्थापक अधिकार दिए जाते हैं। समूह सिंक अक्षम करने के लिए खाली छोड़ें।",
|
||||
"oidcGroupClaim": "समूह दावा",
|
||||
"oidcGroupClaimDesc": "वैकल्पिक। वह दावा पथ जिसमें उपयोगकर्ता के समूह शामिल हैं। डिफ़ॉल्ट रूप से groups, roles, फिर group होता है। कस्टम दावे वाले प्रदाताओं के लिए इसका उपयोग करें (जैसे Zitadel)।",
|
||||
"oidcCaCert": "कस्टम CA प्रमाणपत्र",
|
||||
"oidcCaCertDesc": "वैकल्पिक। निजी या स्व-हस्ताक्षरित CA का उपयोग करने वाले OIDC प्रदाताओं के लिए PEM-एन्कोडेड CA प्रमाणपत्र। सिस्टम ट्रस्ट स्टोर का उपयोग करने के लिए खाली छोड़ें।",
|
||||
"removeOidc": "हटाएं",
|
||||
"usersCount": "{{count}} उपयोगकर्ता",
|
||||
"searchUsers": "उपयोगकर्ताओं को खोजें...",
|
||||
"noUsers": "कोई उपयोगकर्ता नहीं",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Tailscale सेटिंग्स सहेजी गईं",
|
||||
"tailscaleSettingsSaveFailed": "Tailscale सेटिंग्स सहेजने में विफल",
|
||||
"logLevelUpdateFailed": "लॉग स्तर अपडेट करने में विफल",
|
||||
"oidcSaved": "OIDC कॉन्फ़िगरेशन सहेजा गया",
|
||||
"oidcSaveFailed": "OIDC कॉन्फ़िग सहेजने में विफल",
|
||||
"oidcRemoved": "OIDC कॉन्फ़िगरेशन हटाया गया",
|
||||
"oidcRemoveFailed": "OIDC कॉन्फ़िग हटाने में विफल",
|
||||
"createUserRequired": "उपयोगकर्ता नाम और पासवर्ड आवश्यक हैं",
|
||||
"createUserPasswordTooShort": "पासवर्ड कम से कम 6 वर्ण का होना चाहिए",
|
||||
"createUserSuccess": "उपयोगकर्ता \"{{username}}\" बनाया गया",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Az Electron alkalmazás egyelőre nem támogatja a külső hitelesítést. Kérjük, használja a webes verziót az OIDC bejelentkezéshez.",
|
||||
"loginWithProvider": "Bejelentkezés {{name}} használatával",
|
||||
"orContinueWith": "vagy folytassa a",
|
||||
"ldapUsername": "LDAP felhasználónév",
|
||||
"ldapPassword": "LDAP jelszó",
|
||||
"ldapSignIn": "Bejelentkezés",
|
||||
"ldapLoginFailed": "LDAP bejelentkezés sikertelen",
|
||||
"resetPasswordButton": "Jelszó visszaállítása",
|
||||
"sendResetCode": "Visszaállítási kód küldése",
|
||||
"resetCodeDesc": "Add meg a felhasználóneved, hogy jelszó-visszaállító kódot kapj. A kód a Docker konténer naplóiban lesz rögzítve.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Általános",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "SSO-szolgáltatók",
|
||||
"ssoAddProvider": "Szolgáltató hozzáadása",
|
||||
"ssoDocsLink": "Dokumentumok megtekintése",
|
||||
"ssoProviderDocsLink": "Dokumentumok megtekintése",
|
||||
"ssoNoProviders": "Nincsenek konfigurálva SSO-szolgáltatók.",
|
||||
"ssoProviderName": "Megjelenített név",
|
||||
"ssoProviderType": "Szolgáltató típusa",
|
||||
"ssoDeleteProvider": "Szolgáltató törlése",
|
||||
"ssoDeleteConfirm": "Törli ezt a szolgáltatót? A társított felhasználók nem fognak tudni bejelentkezni.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Engedélyezve",
|
||||
"ssoDisabled": "Mozgáskorlátozott",
|
||||
"ssoSaveProvider": "Szolgáltató mentése",
|
||||
"ssoTestConnection": "Tesztkapcsolat",
|
||||
"ssoEditProvider": "Szolgáltató szerkesztése",
|
||||
"ldapHost": "LDAP-gazdagép",
|
||||
"ldapPort": "Kikötő",
|
||||
"ldapUseTls": "TLS (LDAPS) használata",
|
||||
"ldapBindDn": "Kötési DN",
|
||||
"ldapBindPassword": "Jelszó kötése",
|
||||
"ldapUserSearchBase": "Felhasználói keresési bázis",
|
||||
"ldapUserSearchFilter": "Felhasználói keresési szűrő",
|
||||
"ldapUsernameAttr": "Felhasználónév attribútum",
|
||||
"ldapDisplayNameAttr": "Megjelenített név attribútum",
|
||||
"ldapGroupSearchBase": "Csoportkeresési bázis",
|
||||
"ldapAdminGroup": "Adminisztrátori csoport",
|
||||
"ldapAllowedUsers": "Engedélyezett felhasználók",
|
||||
"sectionUsers": "Felhasználók",
|
||||
"sectionSessions": "Munkamenetek",
|
||||
"sectionRoles": "Szerepkörök",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Dokumentumok megtekintése",
|
||||
"tailscaleApiBaseUrl": "API alap URL",
|
||||
"tailscaleApiBaseUrlDescription": "Opcionális. Mutasson egy saját üzemeltetésű Headscale-példányra az api.tailscale.com helyett. Hagyja üresen a Tailscale alapértelmezett értékének használatához.",
|
||||
"oidcDescription": "OpenID Connect konfigurálása SSO-hoz. A csillaggal (*) jelölt mezők kitöltése kötelező.",
|
||||
"oidcDocsLink": "Dokumentumok megtekintése",
|
||||
"oidcClientId": "Ügyfél-azonosító",
|
||||
"oidcClientSecret": "Ügyféltitkos kód",
|
||||
"oidcAuthUrl": "Engedélyezési URL",
|
||||
"oidcIssuerUrl": "Kibocsátó URL-címe",
|
||||
"oidcTokenUrl": "Token URL",
|
||||
"oidcUserIdentifier": "Felhasználói azonosító útvonala",
|
||||
"oidcDisplayName": "Megjelenített név útvonala",
|
||||
"oidcScopes": "Hatókör",
|
||||
"oidcUserinfoUrl": "Felhasználói adatok URL-címének felülbírálása",
|
||||
"oidcAllowedUsers": "Engedélyezett felhasználók",
|
||||
"oidcAllowedUsersDesc": "Egy e-mail cím soronként. Hagyja üresen, ha az összeset engedélyezi.",
|
||||
"oidcAdminGroup": "Adminisztrátori csoport",
|
||||
"oidcAdminGroupDesc": "A csoport felhasználói adminisztrátori jogosultsággal rendelkeznek. Hagyja üresen a csoport szinkronizálásának letiltásához.",
|
||||
"oidcGroupClaim": "Csoportos követelés",
|
||||
"oidcGroupClaimDesc": "Opcionális. A felhasználó csoportjait tartalmazó igénylési útvonal. Alapértelmezés szerint csoportok, szerepkörök, majd csoport. Ezt egyéni igényléssel rendelkező szolgáltatóknál használja (pl. Zitadel).",
|
||||
"oidcCaCert": "Egyéni CA tanúsítvány",
|
||||
"oidcCaCertDesc": "Opcionális. PEM-kódolású CA-tanúsítvány OIDC-szolgáltatók számára, privát vagy önaláírt CA-t használva. Hagyja üresen a rendszer megbízható tárolójának használatához.",
|
||||
"removeOidc": "Eltávolítás",
|
||||
"usersCount": "{{count}} felhasználó",
|
||||
"searchUsers": "Felhasználók keresése...",
|
||||
"noUsers": "Nincsenek felhasználók",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Farokskálázási beállítások mentve",
|
||||
"tailscaleSettingsSaveFailed": "Nem sikerült menteni a farokskálázási beállításokat",
|
||||
"logLevelUpdateFailed": "Nem sikerült frissíteni a naplózási szintet",
|
||||
"oidcSaved": "OIDC konfiguráció mentése",
|
||||
"oidcSaveFailed": "Nem sikerült menteni az OIDC konfigurációt",
|
||||
"oidcRemoved": "OIDC konfiguráció eltávolítva",
|
||||
"oidcRemoveFailed": "Nem sikerült eltávolítani az OIDC konfigurációt",
|
||||
"createUserRequired": "Felhasználónév és jelszó szükséges",
|
||||
"createUserPasswordTooShort": "A jelszónak legalább 6 karakterből kell állnia",
|
||||
"createUserSuccess": "„{{username}}” felhasználó létrehozva",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Autentikasi eksternal belum didukung di aplikasi Electron. Silakan gunakan versi web untuk login OIDC.",
|
||||
"loginWithProvider": "Masuk dengan {{name}}",
|
||||
"orContinueWith": "atau lanjutkan dengan",
|
||||
"ldapUsername": "Nama Pengguna LDAP",
|
||||
"ldapPassword": "Kata Sandi LDAP",
|
||||
"ldapSignIn": "Masuk",
|
||||
"ldapLoginFailed": "Login LDAP gagal",
|
||||
"resetPasswordButton": "Atur Ulang Kata Sandi",
|
||||
"sendResetCode": "Kirim Kode Reset",
|
||||
"resetCodeDesc": "Masukkan nama pengguna Anda untuk menerima kode pengaturan ulang kata sandi. Kode tersebut akan tercatat dalam log kontainer Docker.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Umum",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "Penyedia SSO",
|
||||
"ssoAddProvider": "Tambahkan Penyedia",
|
||||
"ssoDocsLink": "Lihat dokumen",
|
||||
"ssoProviderDocsLink": "Lihat dokumen",
|
||||
"ssoNoProviders": "Tidak ada penyedia SSO yang dikonfigurasi.",
|
||||
"ssoProviderName": "Nama Tampilan",
|
||||
"ssoProviderType": "Jenis Penyedia",
|
||||
"ssoDeleteProvider": "Hapus Penyedia",
|
||||
"ssoDeleteConfirm": "Hapus penyedia ini? Pengguna terkait tidak akan dapat masuk.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Diaktifkan",
|
||||
"ssoDisabled": "Dengan disabilitas",
|
||||
"ssoSaveProvider": "Simpan Penyedia",
|
||||
"ssoTestConnection": "Uji Koneksi",
|
||||
"ssoEditProvider": "Edit Penyedia",
|
||||
"ldapHost": "Host LDAP",
|
||||
"ldapPort": "Pelabuhan",
|
||||
"ldapUseTls": "Gunakan TLS (LDAPS)",
|
||||
"ldapBindDn": "Ikat DN",
|
||||
"ldapBindPassword": "Kata Sandi Terikat",
|
||||
"ldapUserSearchBase": "Basis Pencarian Pengguna",
|
||||
"ldapUserSearchFilter": "Filter Pencarian Pengguna",
|
||||
"ldapUsernameAttr": "Atribut Nama Pengguna",
|
||||
"ldapDisplayNameAttr": "Atribut Nama Tampilan",
|
||||
"ldapGroupSearchBase": "Basis Pencarian Grup",
|
||||
"ldapAdminGroup": "Grup Admin",
|
||||
"ldapAllowedUsers": "Pengguna yang Diizinkan",
|
||||
"sectionUsers": "Pengguna",
|
||||
"sectionSessions": "Sesi",
|
||||
"sectionRoles": "Peran",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Lihat dokumen",
|
||||
"tailscaleApiBaseUrl": "URL Dasar API",
|
||||
"tailscaleApiBaseUrlDescription": "Opsional. Arahkan ke instance Headscale yang dihosting sendiri, bukan api.tailscale.com. Biarkan kosong untuk menggunakan pengaturan default Tailscale.",
|
||||
"oidcDescription": "Konfigurasikan OpenID Connect untuk SSO. Kolom yang ditandai * wajib diisi.",
|
||||
"oidcDocsLink": "Lihat dokumen",
|
||||
"oidcClientId": "ID Klien",
|
||||
"oidcClientSecret": "Rahasia Klien",
|
||||
"oidcAuthUrl": "URL Otorisasi",
|
||||
"oidcIssuerUrl": "URL Penerbit",
|
||||
"oidcTokenUrl": "URL Token",
|
||||
"oidcUserIdentifier": "Jalur Pengidentifikasi Pengguna",
|
||||
"oidcDisplayName": "Jalur Nama Tampilan",
|
||||
"oidcScopes": "Lingkup",
|
||||
"oidcUserinfoUrl": "Ganti URL Informasi Pengguna",
|
||||
"oidcAllowedUsers": "Pengguna yang Diizinkan",
|
||||
"oidcAllowedUsersDesc": "Satu email per baris. Biarkan kosong untuk mengizinkan semua email.",
|
||||
"oidcAdminGroup": "Grup Admin",
|
||||
"oidcAdminGroupDesc": "Pengguna dalam grup ini diberikan hak admin. Biarkan kosong untuk menonaktifkan sinkronisasi grup.",
|
||||
"oidcGroupClaim": "Klaim Kelompok",
|
||||
"oidcGroupClaimDesc": "Opsional. Jalur klaim yang berisi grup pengguna. Secara default berupa grup, peran, lalu grup. Gunakan ini untuk penyedia dengan klaim khusus (misalnya Zitadel).",
|
||||
"oidcCaCert": "Sertifikat CA Kustom",
|
||||
"oidcCaCertDesc": "Opsional. Sertifikat CA berkode PEM untuk penyedia OIDC yang menggunakan CA pribadi atau yang ditandatangani sendiri. Biarkan kosong untuk menggunakan penyimpanan kepercayaan sistem.",
|
||||
"removeOidc": "Menghapus",
|
||||
"usersCount": "{{count}} pengguna",
|
||||
"searchUsers": "Cari pengguna...",
|
||||
"noUsers": "Tidak ada pengguna",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Pengaturan skala ekor tersimpan",
|
||||
"tailscaleSettingsSaveFailed": "Gagal menyimpan pengaturan Tailscale",
|
||||
"logLevelUpdateFailed": "Gagal memperbarui level log.",
|
||||
"oidcSaved": "Konfigurasi OIDC tersimpan",
|
||||
"oidcSaveFailed": "Gagal menyimpan konfigurasi OIDC",
|
||||
"oidcRemoved": "Konfigurasi OIDC dihapus",
|
||||
"oidcRemoveFailed": "Gagal menghapus konfigurasi OIDC",
|
||||
"createUserRequired": "Nama pengguna dan kata sandi diperlukan.",
|
||||
"createUserPasswordTooShort": "Kata sandi harus terdiri minimal 6 karakter.",
|
||||
"createUserSuccess": "Pengguna \"{{username}}\" membuat",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "L'autenticazione esterna non è ancora supportata nell'app Electron. Utilizza la versione web per l'accesso OIDC.",
|
||||
"loginWithProvider": "Accedi con {{name}}",
|
||||
"orContinueWith": "oppure continua con",
|
||||
"ldapUsername": "Nome utente LDAP",
|
||||
"ldapPassword": "Password LDAP",
|
||||
"ldapSignIn": "Accedi",
|
||||
"ldapLoginFailed": "Accesso LDAP non riuscito",
|
||||
"resetPasswordButton": "Reimposta password",
|
||||
"sendResetCode": "Invia codice di reset",
|
||||
"resetCodeDesc": "Inserisci il tuo nome utente per ricevere un codice di reset della password. Il codice verrà registrato nei log del container Docker.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Generale",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "Provider SSO",
|
||||
"ssoAddProvider": "Aggiungi provider",
|
||||
"ssoDocsLink": "Visualizza documentazione",
|
||||
"ssoProviderDocsLink": "Visualizza documentazione",
|
||||
"ssoNoProviders": "Nessun provider SSO configurato.",
|
||||
"ssoProviderName": "Nome visualizzato",
|
||||
"ssoProviderType": "Tipo provider",
|
||||
"ssoDeleteProvider": "Elimina provider",
|
||||
"ssoDeleteConfirm": "Eliminare questo provider? Gli utenti associati non potranno più accedere.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Abilitato",
|
||||
"ssoDisabled": "Disabilitato",
|
||||
"ssoSaveProvider": "Salva provider",
|
||||
"ssoTestConnection": "Test connessione",
|
||||
"ssoEditProvider": "Modifica provider",
|
||||
"ldapHost": "Host LDAP",
|
||||
"ldapPort": "Porta",
|
||||
"ldapUseTls": "Usa TLS (LDAPS)",
|
||||
"ldapBindDn": "Bind DN",
|
||||
"ldapBindPassword": "Password di bind",
|
||||
"ldapUserSearchBase": "Base di ricerca utenti",
|
||||
"ldapUserSearchFilter": "Filtro di ricerca utenti",
|
||||
"ldapUsernameAttr": "Attributo nome utente",
|
||||
"ldapDisplayNameAttr": "Attributo nome visualizzato",
|
||||
"ldapGroupSearchBase": "Base di ricerca gruppi",
|
||||
"ldapAdminGroup": "Gruppo amministratori",
|
||||
"ldapAllowedUsers": "Utenti autorizzati",
|
||||
"sectionUsers": "Utenti",
|
||||
"sectionSessions": "Sessioni",
|
||||
"sectionRoles": "Ruoli",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Visualizza documentazione",
|
||||
"tailscaleApiBaseUrl": "URL di base dell'API",
|
||||
"tailscaleApiBaseUrlDescription": "Opzionale. Specificare un'istanza di Headscale autogestita anziché api.tailscale.com. Lasciare vuoto per utilizzare l'impostazione predefinita di Tailscale.",
|
||||
"oidcDescription": "Configura OpenID Connect per il SSO. I campi contrassegnati con * sono obbligatori.",
|
||||
"oidcDocsLink": "Visualizza documentazione",
|
||||
"oidcClientId": "ID client",
|
||||
"oidcClientSecret": "Segreto client",
|
||||
"oidcAuthUrl": "URL di autorizzazione",
|
||||
"oidcIssuerUrl": "URL dell'emittente",
|
||||
"oidcTokenUrl": "URL token",
|
||||
"oidcUserIdentifier": "Percorso identificatore utente",
|
||||
"oidcDisplayName": "Percorso nome visualizzato",
|
||||
"oidcScopes": "Ambiti",
|
||||
"oidcUserinfoUrl": "Sovrascrivi URL Userinfo",
|
||||
"oidcAllowedUsers": "Utenti consentiti",
|
||||
"oidcAllowedUsersDesc": "Un'email per riga. Lascia vuoto per consentire a tutti.",
|
||||
"oidcAdminGroup": "Gruppo amministratori",
|
||||
"oidcAdminGroupDesc": "Gli utenti di questo gruppo ottengono i diritti di amministratore. Lascia vuoto per disabilitare la sincronizzazione dei gruppi.",
|
||||
"oidcGroupClaim": "Attributo gruppo",
|
||||
"oidcGroupClaimDesc": "Opzionale. Il percorso dell'attributo contenente i gruppi dell'utente. Il valore predefinito è groups, roles, quindi group. Da utilizzare per provider con un attributo personalizzato (es. Zitadel).",
|
||||
"oidcCaCert": "Certificato CA personalizzato",
|
||||
"oidcCaCertDesc": "Opzionale. Certificato CA in formato PEM per provider OIDC che utilizzano una CA privata o autofirmata. Lascia vuoto per usare l'archivio dei certificati attendibili di sistema.",
|
||||
"removeOidc": "Rimuovi",
|
||||
"usersCount": "{{count}} utenti",
|
||||
"searchUsers": "Cerca utenti...",
|
||||
"noUsers": "Nessun utente",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Impostazioni di Tailscale salvate",
|
||||
"tailscaleSettingsSaveFailed": "Salvataggio impostazioni di Tailscale non riuscito",
|
||||
"logLevelUpdateFailed": "Aggiornamento livello di log non riuscito",
|
||||
"oidcSaved": "Configurazione OIDC salvata",
|
||||
"oidcSaveFailed": "Salvataggio configurazione OIDC non riuscito",
|
||||
"oidcRemoved": "Configurazione OIDC rimossa",
|
||||
"oidcRemoveFailed": "Rimozione configurazione OIDC non riuscita",
|
||||
"createUserRequired": "Nome utente e password sono obbligatori",
|
||||
"createUserPasswordTooShort": "La password deve contenere almeno 6 caratteri",
|
||||
"createUserSuccess": "Utente \"{{username}}\" creato",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Electronアプリではまだ外部認証はサポートされていません。OIDCログインにはWeb版をご利用ください。",
|
||||
"loginWithProvider": "{{name}}でログイン",
|
||||
"orContinueWith": "または次で続行",
|
||||
"ldapUsername": "LDAPユーザー名",
|
||||
"ldapPassword": "LDAPパスワード",
|
||||
"ldapSignIn": "サインイン",
|
||||
"ldapLoginFailed": "LDAPログインに失敗しました",
|
||||
"resetPasswordButton": "パスワードをリセット",
|
||||
"sendResetCode": "リセットコードを送信",
|
||||
"resetCodeDesc": "ユーザー名を入力して、パスワードリセットコードを受け取ります。コードはDockerコンテナログに記録されます。",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "全般",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "SSOプロバイダー",
|
||||
"ssoAddProvider": "追加",
|
||||
"ssoDocsLink": "ドキュメントを表示",
|
||||
"ssoProviderDocsLink": "ドキュメントを表示",
|
||||
"ssoNoProviders": "SSOプロバイダーが設定されていません。",
|
||||
"ssoProviderName": "表示名",
|
||||
"ssoProviderType": "プロバイダータイプ",
|
||||
"ssoDeleteProvider": "削除",
|
||||
"ssoDeleteConfirm": "このプロバイダーを削除しますか?関連付けられているユーザーはログインできなくなります。",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "有効",
|
||||
"ssoDisabled": "無効",
|
||||
"ssoSaveProvider": "保存",
|
||||
"ssoTestConnection": "接続テスト",
|
||||
"ssoEditProvider": "編集",
|
||||
"ldapHost": "LDAPホスト",
|
||||
"ldapPort": "ポート",
|
||||
"ldapUseTls": "TLS (LDAPS) を使用",
|
||||
"ldapBindDn": "バインドDN",
|
||||
"ldapBindPassword": "バインドパスワード",
|
||||
"ldapUserSearchBase": "ユーザー検索ベース",
|
||||
"ldapUserSearchFilter": "ユーザー検索フィルター",
|
||||
"ldapUsernameAttr": "ユーザー名属性",
|
||||
"ldapDisplayNameAttr": "表示名属性",
|
||||
"ldapGroupSearchBase": "グループ検索ベース",
|
||||
"ldapAdminGroup": "管理者グループ",
|
||||
"ldapAllowedUsers": "許可ユーザー",
|
||||
"sectionUsers": "ユーザー",
|
||||
"sectionSessions": "セッション",
|
||||
"sectionRoles": "ロール",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "ドキュメントを見る",
|
||||
"tailscaleApiBaseUrl": "APIベースURL",
|
||||
"tailscaleApiBaseUrlDescription": "オプション。api.tailscale.comの代わりに、自己ホスト型のHeadscaleインスタンスを指定します。空欄のままにすると、Tailscaleのデフォルト設定が使用されます。",
|
||||
"oidcDescription": "SSO用のOpenID Connectを設定します。*マークのフィールドは必須です。",
|
||||
"oidcDocsLink": "ドキュメントを見る",
|
||||
"oidcClientId": "クライアントID",
|
||||
"oidcClientSecret": "クライアントシークレット",
|
||||
"oidcAuthUrl": "認証URL",
|
||||
"oidcIssuerUrl": "発行者URL",
|
||||
"oidcTokenUrl": "トークンURL",
|
||||
"oidcUserIdentifier": "ユーザー識別子パス",
|
||||
"oidcDisplayName": "表示名パス",
|
||||
"oidcScopes": "スコープ",
|
||||
"oidcUserinfoUrl": "Userinfo URLの上書き",
|
||||
"oidcAllowedUsers": "許可ユーザー",
|
||||
"oidcAllowedUsersDesc": "1行に1つのメールアドレス。空の場合は全員許可。",
|
||||
"oidcAdminGroup": "管理者グループ",
|
||||
"oidcAdminGroupDesc": "このグループのユーザーは管理者権限が付与されます。空にするとグループ同期は無効です。",
|
||||
"oidcGroupClaim": "グループクレーム",
|
||||
"oidcGroupClaimDesc": "オプション。ユーザーのグループを含むクレームパス。デフォルトは groups, roles, group の順。カスタムクレームを使うプロバイダー用 (例: Zitadel)。",
|
||||
"oidcCaCert": "カスタムCA証明書",
|
||||
"oidcCaCertDesc": "オプション。プライベートまたは自己署名CAを使用するOIDCプロバイダー用のPEMエンコードCA証明書。空の場合はシステムの信頼ストアを使用。",
|
||||
"removeOidc": "削除",
|
||||
"usersCount": "{{count}}人のユーザー",
|
||||
"searchUsers": "ユーザーを検索...",
|
||||
"noUsers": "ユーザーなし",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Tailscale設定を保存しました",
|
||||
"tailscaleSettingsSaveFailed": "Tailscale設定の保存に失敗しました",
|
||||
"logLevelUpdateFailed": "ログレベルの更新に失敗しました",
|
||||
"oidcSaved": "OIDC設定を保存しました",
|
||||
"oidcSaveFailed": "OIDC設定の保存に失敗しました",
|
||||
"oidcRemoved": "OIDC設定を削除しました",
|
||||
"oidcRemoveFailed": "OIDC設定の削除に失敗しました",
|
||||
"createUserRequired": "ユーザー名とパスワードが必要です",
|
||||
"createUserPasswordTooShort": "パスワードは6文字以上必要です",
|
||||
"createUserSuccess": "ユーザー \"{{username}}\" を作成しました",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Electron 앱에서는 아직 외부 인증이 지원되지 않습니다. OIDC 로그인은 웹 버전을 이용하세요.",
|
||||
"loginWithProvider": "{{name}}으로 로그인",
|
||||
"orContinueWith": "또는 다음으로 계속하기",
|
||||
"ldapUsername": "LDAP 사용자 이름",
|
||||
"ldapPassword": "LDAP 비밀번호",
|
||||
"ldapSignIn": "로그인",
|
||||
"ldapLoginFailed": "LDAP 로그인 실패",
|
||||
"resetPasswordButton": "비밀번호 재설정",
|
||||
"sendResetCode": "재설정 코드 전송",
|
||||
"resetCodeDesc": "비밀번호 재설정 코드를 받으려면 사용자 이름을 입력하세요. 코드는 Docker 컨테이너 로그에 기록됩니다.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "일반",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "SSO 제공자",
|
||||
"ssoAddProvider": "제공자 추가",
|
||||
"ssoDocsLink": "문서 보기",
|
||||
"ssoProviderDocsLink": "문서 보기",
|
||||
"ssoNoProviders": "설정된 SSO 제공자가 없습니다.",
|
||||
"ssoProviderName": "표시 이름",
|
||||
"ssoProviderType": "제공자 유형",
|
||||
"ssoDeleteProvider": "제공자 삭제",
|
||||
"ssoDeleteConfirm": "이 제공자를 삭제하시겠습니까? 연결된 사용자는 로그인할 수 없습니다.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "사용",
|
||||
"ssoDisabled": "사용 안 함",
|
||||
"ssoSaveProvider": "제공자 저장",
|
||||
"ssoTestConnection": "연결 테스트",
|
||||
"ssoEditProvider": "제공자 편집",
|
||||
"ldapHost": "LDAP 호스트",
|
||||
"ldapPort": "포트",
|
||||
"ldapUseTls": "TLS 사용 (LDAPS)",
|
||||
"ldapBindDn": "바인딩 DN",
|
||||
"ldapBindPassword": "바인딩 암호",
|
||||
"ldapUserSearchBase": "사용자 검색 기준",
|
||||
"ldapUserSearchFilter": "사용자 검색 필터",
|
||||
"ldapUsernameAttr": "사용자 이름 속성",
|
||||
"ldapDisplayNameAttr": "표시 이름 속성",
|
||||
"ldapGroupSearchBase": "그룹 검색 기준",
|
||||
"ldapAdminGroup": "관리자 그룹",
|
||||
"ldapAllowedUsers": "허용 사용자",
|
||||
"sectionUsers": "사용자",
|
||||
"sectionSessions": "세션",
|
||||
"sectionRoles": "역할",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "문서 보기",
|
||||
"tailscaleApiBaseUrl": "API 기본 URL",
|
||||
"tailscaleApiBaseUrlDescription": "선택 사항입니다. api.tailscale.com 대신 자체 호스팅 Headscale 인스턴스를 가리키도록 설정할 수 있습니다. Tailscale 기본값을 사용하려면 비워 두십시오.",
|
||||
"oidcDescription": "SSO를 위한 OpenID Connect를 구성합니다. * 표시된 필드는 필수입니다.",
|
||||
"oidcDocsLink": "문서 보기",
|
||||
"oidcClientId": "클라이언트 ID",
|
||||
"oidcClientSecret": "클라이언트 시크릿",
|
||||
"oidcAuthUrl": "인증 URL",
|
||||
"oidcIssuerUrl": "Issuer URL",
|
||||
"oidcTokenUrl": "토큰 URL",
|
||||
"oidcUserIdentifier": "사용자 식별자 경로",
|
||||
"oidcDisplayName": "표시 이름 경로",
|
||||
"oidcScopes": "스코프",
|
||||
"oidcUserinfoUrl": "Userinfo URL 재정의",
|
||||
"oidcAllowedUsers": "허용된 사용자",
|
||||
"oidcAllowedUsersDesc": "한 줄에 이메일 하나씩 입력하세요. 비워두면 모두 허용됩니다.",
|
||||
"oidcAdminGroup": "관리자 그룹",
|
||||
"oidcAdminGroupDesc": "이 그룹의 사용자에게 관리자 권한이 부여됩니다. 비워두면 그룹 동기화가 비활성화됩니다.",
|
||||
"oidcGroupClaim": "그룹 클레임",
|
||||
"oidcGroupClaimDesc": "선택 사항입니다. 사용자 그룹이 포함된 클레임 경로입니다. 기본값은 groups, roles, group 순서로 검색됩니다. 사용자 정의 클레임이 있는 제공자(예: Zitadel)의 경우 이 값을 사용하세요.",
|
||||
"oidcCaCert": "사용자 정의 CA 인증서",
|
||||
"oidcCaCertDesc": "선택 사항입니다. 개인 또는 자체 서명 CA를 사용하는 OIDC 제공자를 위한 PEM 인코딩 CA 인증서입니다. 비워두면 시스템 신뢰 저장소가 사용됩니다.",
|
||||
"removeOidc": "제거",
|
||||
"usersCount": "{{count}}명의 사용자",
|
||||
"searchUsers": "사용자 검색...",
|
||||
"noUsers": "사용자 없음",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Tailscale 설정 저장됨",
|
||||
"tailscaleSettingsSaveFailed": "Tailscale 설정 저장 실패",
|
||||
"logLevelUpdateFailed": "로그 레벨 업데이트 실패",
|
||||
"oidcSaved": "OIDC 구성 저장됨",
|
||||
"oidcSaveFailed": "OIDC 구성 저장 실패",
|
||||
"oidcRemoved": "OIDC 구성 제거됨",
|
||||
"oidcRemoveFailed": "OIDC 구성 제거 실패",
|
||||
"createUserRequired": "사용자 이름과 비밀번호는 필수입니다",
|
||||
"createUserPasswordTooShort": "비밀번호는 최소 6자 이상이어야 합니다",
|
||||
"createUserSuccess": "사용자 \"{{username}}\" 생성됨",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Externe authenticatie wordt nog niet ondersteund in de Electron-app. Gebruik de webversie voor OIDC-aanmelding.",
|
||||
"loginWithProvider": "Inloggen met {{name}}",
|
||||
"orContinueWith": "of verdergaan met",
|
||||
"ldapUsername": "LDAP-gebruikersnaam",
|
||||
"ldapPassword": "LDAP-wachtwoord",
|
||||
"ldapSignIn": "Inloggen",
|
||||
"ldapLoginFailed": "LDAP-aanmelding mislukt",
|
||||
"resetPasswordButton": "Wachtwoord opnieuw instellen",
|
||||
"sendResetCode": "Verzend resetcode",
|
||||
"resetCodeDesc": "Voer uw gebruikersnaam in om een code voor het opnieuw instellen van uw wachtwoord te ontvangen. De code wordt opgeslagen in de logbestanden van de Docker-container.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Algemeen",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "SSO-providers",
|
||||
"ssoAddProvider": "Aanbieder toevoegen",
|
||||
"ssoDocsLink": "Documenten bekijken",
|
||||
"ssoProviderDocsLink": "Documenten bekijken",
|
||||
"ssoNoProviders": "Er zijn geen SSO-providers geconfigureerd.",
|
||||
"ssoProviderName": "Weergavenaam",
|
||||
"ssoProviderType": "Aanbiedertype",
|
||||
"ssoDeleteProvider": "Provider verwijderen",
|
||||
"ssoDeleteConfirm": "Deze provider verwijderen? Gekoppelde gebruikers kunnen dan niet meer inloggen.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Ingeschakeld",
|
||||
"ssoDisabled": "Gehandicapt",
|
||||
"ssoSaveProvider": "Aanbieder opslaan",
|
||||
"ssoTestConnection": "Testverbinding",
|
||||
"ssoEditProvider": "Bewerk provider",
|
||||
"ldapHost": "LDAP-host",
|
||||
"ldapPort": "Haven",
|
||||
"ldapUseTls": "Gebruik TLS (LDAPS)",
|
||||
"ldapBindDn": "Bind DN",
|
||||
"ldapBindPassword": "Bind wachtwoord",
|
||||
"ldapUserSearchBase": "Gebruikerszoekbasis",
|
||||
"ldapUserSearchFilter": "Gebruikerszoekfilter",
|
||||
"ldapUsernameAttr": "Gebruikersnaamkenmerk",
|
||||
"ldapDisplayNameAttr": "Weergavenaamkenmerk",
|
||||
"ldapGroupSearchBase": "Groepszoekbasis",
|
||||
"ldapAdminGroup": "Beheerdersgroep",
|
||||
"ldapAllowedUsers": "Toegestane gebruikers",
|
||||
"sectionUsers": "Gebruikers",
|
||||
"sectionSessions": "Sessies",
|
||||
"sectionRoles": "Rollen",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Documenten bekijken",
|
||||
"tailscaleApiBaseUrl": "API-basis-URL",
|
||||
"tailscaleApiBaseUrlDescription": "Optioneel. Verwijs naar een zelfgehoste Headscale-instantie in plaats van api.tailscale.com. Laat dit veld leeg om de standaard Tailscale-instantie te gebruiken.",
|
||||
"oidcDescription": "Configureer OpenID Connect voor SSO. Velden gemarkeerd met een * zijn verplicht.",
|
||||
"oidcDocsLink": "Documenten bekijken",
|
||||
"oidcClientId": "Klant-ID",
|
||||
"oidcClientSecret": "Klantgeheim",
|
||||
"oidcAuthUrl": "Autorisatie-URL",
|
||||
"oidcIssuerUrl": "URL van de uitgever",
|
||||
"oidcTokenUrl": "Token-URL",
|
||||
"oidcUserIdentifier": "Gebruikersidentificatiepad",
|
||||
"oidcDisplayName": "Weergavenaam Pad",
|
||||
"oidcScopes": "Scopes",
|
||||
"oidcUserinfoUrl": "Gebruikersinfo-URL overschrijven",
|
||||
"oidcAllowedUsers": "Toegestane gebruikers",
|
||||
"oidcAllowedUsersDesc": "Eén e-mailadres per regel. Laat dit veld leeg om alle e-mailadressen toe te staan.",
|
||||
"oidcAdminGroup": "Beheerdersgroep",
|
||||
"oidcAdminGroupDesc": "Gebruikers in deze groep hebben beheerdersrechten. Laat dit veld leeg om groepssynchronisatie uit te schakelen.",
|
||||
"oidcGroupClaim": "Groepsclaim",
|
||||
"oidcGroupClaimDesc": "Optioneel. Het pad naar de claim dat de groepen van de gebruiker bevat. Standaard is dit: groepen, rollen, vervolgens groep. Gebruik dit voor providers met een aangepaste claim (bijv. Zitadel).",
|
||||
"oidcCaCert": "Aangepast CA-certificaat",
|
||||
"oidcCaCertDesc": "Optioneel. PEM-gecodeerd CA-certificaat voor OIDC-providers die gebruikmaken van een privé- of zelfondertekende CA. Laat dit veld leeg om het systeemvertrouwensarchief te gebruiken.",
|
||||
"removeOidc": "Verwijderen",
|
||||
"usersCount": "{{count}} gebruikers",
|
||||
"searchUsers": "Gebruikers doorzoeken...",
|
||||
"noUsers": "Geen gebruikers",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Staartschaalinstellingen opgeslagen",
|
||||
"tailscaleSettingsSaveFailed": "Het opslaan van de Tailscale-instellingen is mislukt.",
|
||||
"logLevelUpdateFailed": "Het bijwerken van het logniveau is mislukt.",
|
||||
"oidcSaved": "OIDC-configuratie opgeslagen",
|
||||
"oidcSaveFailed": "Het opslaan van de OIDC-configuratie is mislukt.",
|
||||
"oidcRemoved": "OIDC-configuratie verwijderd",
|
||||
"oidcRemoveFailed": "Het verwijderen van de OIDC-configuratie is mislukt.",
|
||||
"createUserRequired": "Gebruikersnaam en wachtwoord zijn vereist.",
|
||||
"createUserPasswordTooShort": "Het wachtwoord moet minimaal 6 tekens lang zijn.",
|
||||
"createUserSuccess": "Gebruiker \"{{username}}\" heeft gemaakt",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Ekstern autentisering støttes ikke i Electron-appen ennå. Bruk nettversjonen for OIDC-pålogging.",
|
||||
"loginWithProvider": "Logg inn med {{name}}",
|
||||
"orContinueWith": "eller fortsette med",
|
||||
"ldapUsername": "LDAP-brukernavn",
|
||||
"ldapPassword": "LDAP-passord",
|
||||
"ldapSignIn": "Logg inn",
|
||||
"ldapLoginFailed": "LDAP-pålogging mislyktes",
|
||||
"resetPasswordButton": "Tilbakestill passord",
|
||||
"sendResetCode": "Send tilbakestillingskode",
|
||||
"resetCodeDesc": "Skriv inn brukernavnet ditt for å motta en kode for tilbakestilling av passord. Koden vil bli logget i loggene for docker-containeren.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "General",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "SSO-leverandører",
|
||||
"ssoAddProvider": "Legg til leverandør",
|
||||
"ssoDocsLink": "Vis dokumenter",
|
||||
"ssoProviderDocsLink": "Vis dokumenter",
|
||||
"ssoNoProviders": "Ingen SSO-leverandører er konfigurert.",
|
||||
"ssoProviderName": "Visningsnavn",
|
||||
"ssoProviderType": "Leverandørtype",
|
||||
"ssoDeleteProvider": "Slett leverandør",
|
||||
"ssoDeleteConfirm": "Vil du slette denne leverandøren? Tilknyttede brukere vil ikke kunne logge inn.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Aktivert",
|
||||
"ssoDisabled": "Funksjonshemmet",
|
||||
"ssoSaveProvider": "Lagre leverandør",
|
||||
"ssoTestConnection": "Testtilkobling",
|
||||
"ssoEditProvider": "Rediger leverandør",
|
||||
"ldapHost": "LDAP-vert",
|
||||
"ldapPort": "Havn",
|
||||
"ldapUseTls": "Bruk TLS (LDAPS)",
|
||||
"ldapBindDn": "Bind DN",
|
||||
"ldapBindPassword": "Bind passord",
|
||||
"ldapUserSearchBase": "Brukersøkbase",
|
||||
"ldapUserSearchFilter": "Brukersøkfilter",
|
||||
"ldapUsernameAttr": "Brukernavnattributt",
|
||||
"ldapDisplayNameAttr": "Visningsnavnattributt",
|
||||
"ldapGroupSearchBase": "Gruppesøkbase",
|
||||
"ldapAdminGroup": "Admingruppe",
|
||||
"ldapAllowedUsers": "Tillatte brukere",
|
||||
"sectionUsers": "Brukere",
|
||||
"sectionSessions": "Økter",
|
||||
"sectionRoles": "Roller",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Vis dokumenter",
|
||||
"tailscaleApiBaseUrl": "API-basis-URL",
|
||||
"tailscaleApiBaseUrlDescription": "Valgfritt. Pek på en selvhostet Headscale-instans i stedet for api.tailscale.com. La stå tomt for å bruke standardinnstillingen for Tailscale.",
|
||||
"oidcDescription": "Konfigurer OpenID Connect for SSO. Felt merket med * er obligatoriske.",
|
||||
"oidcDocsLink": "Vis dokumenter",
|
||||
"oidcClientId": "Klient-ID",
|
||||
"oidcClientSecret": "Klienthemmelighet",
|
||||
"oidcAuthUrl": "Autorisasjons-URL",
|
||||
"oidcIssuerUrl": "Utsteder-URL",
|
||||
"oidcTokenUrl": "Token-URL",
|
||||
"oidcUserIdentifier": "Brukeridentifikatorsti",
|
||||
"oidcDisplayName": "Sti til visningsnavn",
|
||||
"oidcScopes": "Omfang",
|
||||
"oidcUserinfoUrl": "Overstyr brukerinfo-URL",
|
||||
"oidcAllowedUsers": "Tillatte brukere",
|
||||
"oidcAllowedUsersDesc": "Én e-post per linje. La stå tomt for å tillate alle.",
|
||||
"oidcAdminGroup": "Admingruppe",
|
||||
"oidcAdminGroupDesc": "Brukere i denne gruppen har administratorrettigheter. La feltet stå tomt for å deaktivere gruppesynkronisering.",
|
||||
"oidcGroupClaim": "Gruppekrav",
|
||||
"oidcGroupClaimDesc": "Valgfritt. Kravbanen som inneholder brukerens grupper. Standardinnstillingen er grupper, roller og deretter gruppe. Bruk dette for leverandører med et tilpasset krav (f.eks. Zitadel).",
|
||||
"oidcCaCert": "Tilpasset CA-sertifikat",
|
||||
"oidcCaCertDesc": "Valgfritt. PEM-kodet CA-sertifikat for OIDC-leverandører som bruker en privat eller selvsignert CA. La stå tomt for å bruke systemets klareringslager.",
|
||||
"removeOidc": "Fjerne",
|
||||
"usersCount": "{{count}} brukere",
|
||||
"searchUsers": "Søk etter brukere...",
|
||||
"noUsers": "Ingen brukere",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Haleskalainnstillinger lagret",
|
||||
"tailscaleSettingsSaveFailed": "Kunne ikke lagre Tailscale-innstillingene",
|
||||
"logLevelUpdateFailed": "Kunne ikke oppdatere loggnivået",
|
||||
"oidcSaved": "OIDC-konfigurasjon lagret",
|
||||
"oidcSaveFailed": "Kunne ikke lagre OIDC-konfigurasjonen",
|
||||
"oidcRemoved": "OIDC-konfigurasjon fjernet",
|
||||
"oidcRemoveFailed": "Kunne ikke fjerne OIDC-konfigurasjonen",
|
||||
"createUserRequired": "Brukernavn og passord kreves",
|
||||
"createUserPasswordTooShort": "Passordet må være minst 6 tegn",
|
||||
"createUserSuccess": "Bruker «{{username}}» opprettet",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Uwierzytelnianie zewnętrzne nie jest jeszcze obsługiwane w aplikacji Electron. Do logowania OIDC użyj wersji przeglądarkowej.",
|
||||
"loginWithProvider": "Zaloguj się za pomocą {{name}}",
|
||||
"orContinueWith": "lub kontynuuj przez",
|
||||
"ldapUsername": "Nazwa użytkownika LDAP",
|
||||
"ldapPassword": "Hasło LDAP",
|
||||
"ldapSignIn": "Zaloguj się",
|
||||
"ldapLoginFailed": "Nie udało się zalogować do LDAP",
|
||||
"resetPasswordButton": "Zresetuj hasło",
|
||||
"sendResetCode": "Wyślij kod resetowania",
|
||||
"resetCodeDesc": "Wprowadź nazwę użytkownika, aby otrzymać kod resetowania hasła. Kod pojawi się w logach kontenera Docker.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Ogólne",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "Dostawcy SSO",
|
||||
"ssoAddProvider": "Dodaj dostawcę",
|
||||
"ssoDocsLink": "Zobacz dokumentację",
|
||||
"ssoProviderDocsLink": "Zobacz dokumentację",
|
||||
"ssoNoProviders": "Nie skonfigurowano dostawców SSO.",
|
||||
"ssoProviderName": "Nazwa wyświetlana",
|
||||
"ssoProviderType": "Typ dostawcy",
|
||||
"ssoDeleteProvider": "Usuń dostawcę",
|
||||
"ssoDeleteConfirm": "Usunąć tego dostawcę? Powiązani użytkownicy nie będą mogli się zalogować.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Włączone",
|
||||
"ssoDisabled": "Wyłączone",
|
||||
"ssoSaveProvider": "Zapisz dostawcę",
|
||||
"ssoTestConnection": "Testuj połączenie",
|
||||
"ssoEditProvider": "Edytuj dostawcę",
|
||||
"ldapHost": "Host LDAP",
|
||||
"ldapPort": "Port",
|
||||
"ldapUseTls": "Użyj TLS (LDAPS)",
|
||||
"ldapBindDn": "Bind DN",
|
||||
"ldapBindPassword": "Hasło Bind",
|
||||
"ldapUserSearchBase": "Baza wyszukiwania użytkowników",
|
||||
"ldapUserSearchFilter": "Filtr wyszukiwania użytkownika",
|
||||
"ldapUsernameAttr": "Atrybut nazwy użytkownika",
|
||||
"ldapDisplayNameAttr": "Atrybut nazwy wyświetlanej",
|
||||
"ldapGroupSearchBase": "Baza wyszukiwania grup",
|
||||
"ldapAdminGroup": "Grupa administratorów",
|
||||
"ldapAllowedUsers": "Dozwoleni użytkownicy",
|
||||
"sectionUsers": "Użytkownicy",
|
||||
"sectionSessions": "Sesje",
|
||||
"sectionRoles": "Role",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Zobacz dokumentację",
|
||||
"tailscaleApiBaseUrl": "Podstawowy adres URL API",
|
||||
"tailscaleApiBaseUrlDescription": "Opcjonalnie. Wskaż samodzielnie hostowaną instancję Headscale zamiast api.tailscale.com. Pozostaw puste, aby użyć domyślnej instancji Tailscale.",
|
||||
"oidcDescription": "Skonfiguruj OpenID Connect dla logowania jednokrotnego (SSO). Pola oznaczone * są wymagane.",
|
||||
"oidcDocsLink": "Zobacz dokumentację",
|
||||
"oidcClientId": "Identyfikator klienta",
|
||||
"oidcClientSecret": "Sekret klienta",
|
||||
"oidcAuthUrl": "Adres URL autoryzacji",
|
||||
"oidcIssuerUrl": "Adres URL wystawcy",
|
||||
"oidcTokenUrl": "Adres URL tokena",
|
||||
"oidcUserIdentifier": "Ścieżka identyfikatora użytkownika",
|
||||
"oidcDisplayName": "Ścieżka nazwy wyświetlanej",
|
||||
"oidcScopes": "Zakresy",
|
||||
"oidcUserinfoUrl": "Zastąp adres URL informacji o użytkowniku",
|
||||
"oidcAllowedUsers": "Dozwoleni użytkownicy",
|
||||
"oidcAllowedUsersDesc": "Jeden adres e-mail w każdym wierszu. Pozostaw puste, aby zezwolić na wszystkie.",
|
||||
"oidcAdminGroup": "Grupa administratorów",
|
||||
"oidcAdminGroupDesc": "Użytkownicy w tej grupie otrzymują uprawnienia administratora. Pozostaw puste, aby wyłączyć synchronizację grupy.",
|
||||
"oidcGroupClaim": "Claim grupy",
|
||||
"oidcGroupClaimDesc": "Opcjonalnie. Ścieżka claimu zawierającego grupy użytkownika. Domyślnie: groups, roles, potem group. Użyj tego dla dostawców z niestandardowym claimem (np. Zitadel).",
|
||||
"oidcCaCert": "Niestandardowy certyfikat CA",
|
||||
"oidcCaCertDesc": "Opcjonalnie. Certyfikat CA w formacie PEM dla dostawców OIDC korzystających z prywatnego lub samopodpisanego CA. Pozostaw puste, aby użyć systemowego magazynu zaufanych certyfikatów.",
|
||||
"removeOidc": "Usuń",
|
||||
"usersCount": "{{count}} użytkowników",
|
||||
"searchUsers": "Wyszukaj użytkowników...",
|
||||
"noUsers": "Brak użytkowników",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Zapisano ustawienia Tailscale",
|
||||
"tailscaleSettingsSaveFailed": "Nie udało się zapisać ustawień Tailscale",
|
||||
"logLevelUpdateFailed": "Nie udało się zaktualizować poziomu logowania",
|
||||
"oidcSaved": "Zapisano konfigurację OIDC",
|
||||
"oidcSaveFailed": "Nie udało się zapisać konfiguracji OIDC",
|
||||
"oidcRemoved": "Usunięto konfigurację OIDC",
|
||||
"oidcRemoveFailed": "Nie udało się usunąć konfiguracji OIDC",
|
||||
"createUserRequired": "Wymagana jest nazwa użytkownika i hasło",
|
||||
"createUserPasswordTooShort": "Hasło musi mieć co najmniej 6 znaków",
|
||||
"createUserSuccess": "Utworzono użytkownika „{{username}}”",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "A autenticação externa ainda não é suportada no aplicativo Electron. Por favor, utilize a versão web para login OIDC.",
|
||||
"loginWithProvider": "Faça login com {{name}}",
|
||||
"orContinueWith": "ou continue com",
|
||||
"ldapUsername": "Nome de usuário LDAP",
|
||||
"ldapPassword": "Senha LDAP",
|
||||
"ldapSignIn": "Entrar",
|
||||
"ldapLoginFailed": "Falha no login LDAP",
|
||||
"resetPasswordButton": "Redefinir senha",
|
||||
"sendResetCode": "Enviar código de reinicialização",
|
||||
"resetCodeDesc": "Digite seu nome de usuário para receber um código de redefinição de senha. O código será registrado nos logs do contêiner Docker.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Em geral",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "Provedores de SSO",
|
||||
"ssoAddProvider": "Adicionar provedor",
|
||||
"ssoDocsLink": "Ver documentos",
|
||||
"ssoProviderDocsLink": "Ver documentos",
|
||||
"ssoNoProviders": "Nenhum provedor de SSO configurado.",
|
||||
"ssoProviderName": "Nome de exibição",
|
||||
"ssoProviderType": "Tipo de provedor",
|
||||
"ssoDeleteProvider": "Excluir provedor",
|
||||
"ssoDeleteConfirm": "Excluir este provedor? Os usuários associados não poderão fazer login.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Habilitado",
|
||||
"ssoDisabled": "Desabilitado",
|
||||
"ssoSaveProvider": "Salvar provedor",
|
||||
"ssoTestConnection": "Conexão de teste",
|
||||
"ssoEditProvider": "Editar provedor",
|
||||
"ldapHost": "Host LDAP",
|
||||
"ldapPort": "Porta",
|
||||
"ldapUseTls": "Usar TLS (LDAPS)",
|
||||
"ldapBindDn": "Vincular DN",
|
||||
"ldapBindPassword": "Senha de vinculação",
|
||||
"ldapUserSearchBase": "Base de pesquisa de usuários",
|
||||
"ldapUserSearchFilter": "Filtro de pesquisa do usuário",
|
||||
"ldapUsernameAttr": "Atributo de nome de usuário",
|
||||
"ldapDisplayNameAttr": "Atributo de nome de exibição",
|
||||
"ldapGroupSearchBase": "Base de pesquisa de grupo",
|
||||
"ldapAdminGroup": "Grupo de Administração",
|
||||
"ldapAllowedUsers": "Usuários autorizados",
|
||||
"sectionUsers": "Usuários",
|
||||
"sectionSessions": "Sessões",
|
||||
"sectionRoles": "Funções",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Ver documentos",
|
||||
"tailscaleApiBaseUrl": "URL base da API",
|
||||
"tailscaleApiBaseUrlDescription": "Opcional. Aponte para uma instância auto-hospedada do Headscale em vez de api.tailscale.com. Deixe em branco para usar o padrão do Tailscale.",
|
||||
"oidcDescription": "Configure o OpenID Connect para SSO. Os campos marcados com * são obrigatórios.",
|
||||
"oidcDocsLink": "Ver documentos",
|
||||
"oidcClientId": "ID do cliente",
|
||||
"oidcClientSecret": "Segredo do cliente",
|
||||
"oidcAuthUrl": "URL de autorização",
|
||||
"oidcIssuerUrl": "URL do emissor",
|
||||
"oidcTokenUrl": "URL do token",
|
||||
"oidcUserIdentifier": "Caminho do identificador do usuário",
|
||||
"oidcDisplayName": "Nome de exibição Caminho",
|
||||
"oidcScopes": "Escopos",
|
||||
"oidcUserinfoUrl": "Substituir URL de informações do usuário",
|
||||
"oidcAllowedUsers": "Usuários autorizados",
|
||||
"oidcAllowedUsersDesc": "Um e-mail por linha. Deixe em branco para permitir todos.",
|
||||
"oidcAdminGroup": "Grupo de Administração",
|
||||
"oidcAdminGroupDesc": "Os usuários deste grupo têm privilégios de administrador. Deixe em branco para desativar a sincronização do grupo.",
|
||||
"oidcGroupClaim": "Reivindicação de grupo",
|
||||
"oidcGroupClaimDesc": "Opcional. O caminho da declaração que contém os grupos do usuário. O padrão é grupos, funções e, em seguida, grupo. Use isso para provedores com uma declaração personalizada (por exemplo, Zitadel).",
|
||||
"oidcCaCert": "Certificado CA personalizado",
|
||||
"oidcCaCertDesc": "Opcional. Certificado CA codificado em PEM para provedores OIDC que utilizam uma CA privada ou autoassinada. Deixe em branco para usar o repositório de certificados confiáveis do sistema.",
|
||||
"removeOidc": "Remover",
|
||||
"usersCount": "{{count}} usuários",
|
||||
"searchUsers": "Pesquisar usuários...",
|
||||
"noUsers": "Nenhum usuário",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Configurações da balança de cauda salvas",
|
||||
"tailscaleSettingsSaveFailed": "Falha ao salvar as configurações do Tailscale",
|
||||
"logLevelUpdateFailed": "Falha ao atualizar o nível de registro.",
|
||||
"oidcSaved": "Configuração OIDC salva",
|
||||
"oidcSaveFailed": "Falha ao salvar a configuração OIDC",
|
||||
"oidcRemoved": "Configuração OIDC removida",
|
||||
"oidcRemoveFailed": "Falha ao remover a configuração OIDC",
|
||||
"createUserRequired": "É necessário nome de usuário e senha.",
|
||||
"createUserPasswordTooShort": "A senha deve ter pelo menos 6 caracteres.",
|
||||
"createUserSuccess": "Usuário \"{{username}}\" criou",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "A autenticação externa ainda não é suportada na aplicação Electron. Utilize a versão web para iniciar sessão via OIDC.",
|
||||
"loginWithProvider": "Iniciar sessão com {{name}}",
|
||||
"orContinueWith": "ou continuar com",
|
||||
"ldapUsername": "Nome de utilizador LDAP",
|
||||
"ldapPassword": "Palavra-passe LDAP",
|
||||
"ldapSignIn": "Iniciar sessão",
|
||||
"ldapLoginFailed": "Falha no início de sessão LDAP",
|
||||
"resetPasswordButton": "Redefinir palavra-passe",
|
||||
"sendResetCode": "Enviar código de reposição",
|
||||
"resetCodeDesc": "Introduza o seu nome de utilizador para receber um código de reposição de palavra-passe. O código será registado nos logs do contentor Docker.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Geral",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "Provedores de SSO",
|
||||
"ssoAddProvider": "Adicionar Provedor",
|
||||
"ssoDocsLink": "Ver docs",
|
||||
"ssoProviderDocsLink": "Ver docs",
|
||||
"ssoNoProviders": "Nenhum provedor de SSO configurado.",
|
||||
"ssoProviderName": "Nome de apresentação",
|
||||
"ssoProviderType": "Tipo de Provedor",
|
||||
"ssoDeleteProvider": "Eliminar Provedor",
|
||||
"ssoDeleteConfirm": "Eliminar este provedor? Os utilizadores associados não poderão iniciar sessão.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Ativado",
|
||||
"ssoDisabled": "Desativado",
|
||||
"ssoSaveProvider": "Guardar Provedor",
|
||||
"ssoTestConnection": "Testar Ligação",
|
||||
"ssoEditProvider": "Editar Provedor",
|
||||
"ldapHost": "Servidor LDAP",
|
||||
"ldapPort": "Porta",
|
||||
"ldapUseTls": "Usar TLS (LDAPS)",
|
||||
"ldapBindDn": "Bind DN",
|
||||
"ldapBindPassword": "Palavra-passe de Bind",
|
||||
"ldapUserSearchBase": "Base de pesquisa de utilizadores",
|
||||
"ldapUserSearchFilter": "Filtro de pesquisa de utilizadores",
|
||||
"ldapUsernameAttr": "Atributo de nome de utilizador",
|
||||
"ldapDisplayNameAttr": "Atributo de nome de apresentação",
|
||||
"ldapGroupSearchBase": "Base de pesquisa de grupos",
|
||||
"ldapAdminGroup": "Grupo de administradores",
|
||||
"ldapAllowedUsers": "Utilizadores permitidos",
|
||||
"sectionUsers": "Utilizadores",
|
||||
"sectionSessions": "Sessões",
|
||||
"sectionRoles": "Funções",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Ver documentação",
|
||||
"tailscaleApiBaseUrl": "URL base da API",
|
||||
"tailscaleApiBaseUrlDescription": "Opcional. Aponte para uma instância auto-hospedada do Headscale em vez de api.tailscale.com. Deixe em branco para utilizar o padrão do Tailscale.",
|
||||
"oidcDescription": "Configurar o OpenID Connect para SSO. Os campos marcados com * são obrigatórios.",
|
||||
"oidcDocsLink": "Ver documentação",
|
||||
"oidcClientId": "ID do cliente",
|
||||
"oidcClientSecret": "Segredo do cliente",
|
||||
"oidcAuthUrl": "URL de autorização",
|
||||
"oidcIssuerUrl": "URL do emissor",
|
||||
"oidcTokenUrl": "URL do token",
|
||||
"oidcUserIdentifier": "Caminho do identificador do utilizador",
|
||||
"oidcDisplayName": "Caminho do nome de exibição",
|
||||
"oidcScopes": "Âmbitos",
|
||||
"oidcUserinfoUrl": "Substituir URL do Userinfo",
|
||||
"oidcAllowedUsers": "Utilizadores permitidos",
|
||||
"oidcAllowedUsersDesc": "Um email por linha. Deixe em branco para permitir todos.",
|
||||
"oidcAdminGroup": "Grupo de administração",
|
||||
"oidcAdminGroupDesc": "Os utilizadores neste grupo recebem privilégios de administrador. Deixe em branco para desativar a sincronização de grupos.",
|
||||
"oidcGroupClaim": "Atributo de grupo",
|
||||
"oidcGroupClaimDesc": "Opcional. O caminho do atributo que contém os grupos do utilizador. Predefinido para groups, roles e depois group. Utilize para fornecedores com um atributo personalizado (ex.: Zitadel).",
|
||||
"oidcCaCert": "Certificado CA personalizado",
|
||||
"oidcCaCertDesc": "Opcional. Certificado CA codificado em PEM para fornecedores OIDC que utilizam uma CA privada ou autoassinada. Deixe em branco para utilizar o repositório de confiança do sistema.",
|
||||
"removeOidc": "Remover",
|
||||
"usersCount": "{{count}} utilizadores",
|
||||
"searchUsers": "Pesquisar utilizadores...",
|
||||
"noUsers": "Nenhum utilizador",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Definições do Tailscale guardadas",
|
||||
"tailscaleSettingsSaveFailed": "Falha ao guardar definições do Tailscale",
|
||||
"logLevelUpdateFailed": "Falha ao atualizar nível de registo",
|
||||
"oidcSaved": "Configuração OIDC guardada",
|
||||
"oidcSaveFailed": "Falha ao guardar configuração OIDC",
|
||||
"oidcRemoved": "Configuração OIDC removida",
|
||||
"oidcRemoveFailed": "Falha ao remover configuração OIDC",
|
||||
"createUserRequired": "Nome de utilizador e palavra-passe são obrigatórios",
|
||||
"createUserPasswordTooShort": "A palavra-passe deve ter pelo menos 6 caracteres",
|
||||
"createUserSuccess": "Utilizador \"{{username}}\" criado",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Autentificarea externă nu este încă acceptată în aplicația Electron. Vă rugăm să utilizați versiunea web pentru conectarea la OIDC.",
|
||||
"loginWithProvider": "Autentificare cu {{name}}",
|
||||
"orContinueWith": "sau continuați cu",
|
||||
"ldapUsername": "Nume de utilizator LDAP",
|
||||
"ldapPassword": "Parolă LDAP",
|
||||
"ldapSignIn": "Conectare",
|
||||
"ldapLoginFailed": "Conectarea LDAP a eșuat",
|
||||
"resetPasswordButton": "Resetare parolă",
|
||||
"sendResetCode": "Trimiteți codul de resetare",
|
||||
"resetCodeDesc": "Introdu numele de utilizator pentru a primi un cod de resetare a parolei. Codul va fi înregistrat în jurnalele containerului Docker.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "General",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "Furnizori SSO",
|
||||
"ssoAddProvider": "Adăugați furnizor",
|
||||
"ssoDocsLink": "Vizualizați documentele",
|
||||
"ssoProviderDocsLink": "Vizualizați documentele",
|
||||
"ssoNoProviders": "Nu sunt configurați furnizori SSO.",
|
||||
"ssoProviderName": "Numele de afișare",
|
||||
"ssoProviderType": "Tipul furnizorului",
|
||||
"ssoDeleteProvider": "Ștergeți furnizorul",
|
||||
"ssoDeleteConfirm": "Ștergeți acest furnizor? Utilizatorii asociați nu se vor putea conecta.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Activat",
|
||||
"ssoDisabled": "Persoane cu dizabilități",
|
||||
"ssoSaveProvider": "Salvați furnizorul",
|
||||
"ssoTestConnection": "Testați conexiunea",
|
||||
"ssoEditProvider": "Editați furnizorul",
|
||||
"ldapHost": "Gazdă LDAP",
|
||||
"ldapPort": "Port",
|
||||
"ldapUseTls": "Utilizați TLS (LDAPS)",
|
||||
"ldapBindDn": "Legați DN-ul",
|
||||
"ldapBindPassword": "Parolă de legare",
|
||||
"ldapUserSearchBase": "Baza de căutare a utilizatorilor",
|
||||
"ldapUserSearchFilter": "Filtru de căutare utilizator",
|
||||
"ldapUsernameAttr": "Atribut nume utilizator",
|
||||
"ldapDisplayNameAttr": "Atributul numelui afișat",
|
||||
"ldapGroupSearchBase": "Baza de căutare a grupului",
|
||||
"ldapAdminGroup": "Grup de administratori",
|
||||
"ldapAllowedUsers": "Utilizatori permiși",
|
||||
"sectionUsers": "Utilizatori",
|
||||
"sectionSessions": "Sesiuni",
|
||||
"sectionRoles": "Roluri",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Vizualizați documentele",
|
||||
"tailscaleApiBaseUrl": "URL-ul de bază al API-ului",
|
||||
"tailscaleApiBaseUrlDescription": "Opțional. Indicați către o instanță Headscale auto-găzduită în loc de api.tailscale.com. Lăsați câmpul gol pentru a utiliza valoarea implicită Tailscale.",
|
||||
"oidcDescription": "Configurați OpenID Connect pentru SSO. Câmpurile marcate cu * sunt obligatorii.",
|
||||
"oidcDocsLink": "Vizualizați documentele",
|
||||
"oidcClientId": "ID client",
|
||||
"oidcClientSecret": "Secretul clientului",
|
||||
"oidcAuthUrl": "URL de autorizare",
|
||||
"oidcIssuerUrl": "URL-ul emitentului",
|
||||
"oidcTokenUrl": "Adresa URL a jetonului",
|
||||
"oidcUserIdentifier": "Calea de identificare a utilizatorului",
|
||||
"oidcDisplayName": "Calea numelui afișat",
|
||||
"oidcScopes": "Scopuri",
|
||||
"oidcUserinfoUrl": "Suprascrieți adresa URL a informațiilor utilizatorului",
|
||||
"oidcAllowedUsers": "Utilizatori permiși",
|
||||
"oidcAllowedUsersDesc": "Un e-mail pe rând. Lăsați necompletat pentru a permite accesul tuturor.",
|
||||
"oidcAdminGroup": "Grup de administratori",
|
||||
"oidcAdminGroupDesc": "Utilizatorii din acest grup au drepturi de administrator. Lăsați câmpul gol pentru a dezactiva sincronizarea grupului.",
|
||||
"oidcGroupClaim": "Revendicare de grup",
|
||||
"oidcGroupClaimDesc": "Opțional. Calea de revendicare care conține grupurile utilizatorului. Implicit, se utilizează grupuri, roluri, apoi grup. Folosiți această opțiune pentru furnizorii cu o revendicare personalizată (de exemplu, Zitadel).",
|
||||
"oidcCaCert": "Certificat CA personalizat",
|
||||
"oidcCaCertDesc": "Opțional. Certificat CA codificat PEM pentru furnizorii OIDC care utilizează un CA privat sau autosemnat. Lăsați câmpul gol pentru a utiliza depozitul de încredere al sistemului.",
|
||||
"removeOidc": "Elimina",
|
||||
"usersCount": "utilizatori {{count}}",
|
||||
"searchUsers": "Căutați utilizatori...",
|
||||
"noUsers": "Niciun utilizator",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Setările de coală inferioară au fost salvate",
|
||||
"tailscaleSettingsSaveFailed": "Setările Tailscale nu au putut fi salvate.",
|
||||
"logLevelUpdateFailed": "Nivelul de jurnalizare nu a putut fi actualizat",
|
||||
"oidcSaved": "Configurația OIDC a fost salvată",
|
||||
"oidcSaveFailed": "Configurația OIDC nu a putut fi salvată.",
|
||||
"oidcRemoved": "Configurația OIDC a fost eliminată",
|
||||
"oidcRemoveFailed": "Configurația OIDC nu a putut fi eliminată.",
|
||||
"createUserRequired": "Numele de utilizator și parola sunt obligatorii",
|
||||
"createUserPasswordTooShort": "Parola trebuie să aibă cel puțin 6 caractere",
|
||||
"createUserSuccess": "Utilizatorul „{{username}}” a fost creat",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Внешняя аутентификация пока не поддерживается в приложении Electron. Пожалуйста, используйте веб-версию для входа через OIDC.",
|
||||
"loginWithProvider": "Войти через {{name}}",
|
||||
"orContinueWith": "или продолжить с",
|
||||
"ldapUsername": "Имя пользователя LDAP",
|
||||
"ldapPassword": "Пароль LDAP",
|
||||
"ldapSignIn": "Войти",
|
||||
"ldapLoginFailed": "Ошибка входа LDAP",
|
||||
"resetPasswordButton": "Сбросить пароль",
|
||||
"sendResetCode": "Отправить код сброса",
|
||||
"resetCodeDesc": "Введите имя пользователя, чтобы получить код сброса пароля. Код будет записан в логи контейнера Docker.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Общие",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "Провайдеры SSO",
|
||||
"ssoAddProvider": "Добавить провайдера",
|
||||
"ssoDocsLink": "Документация",
|
||||
"ssoProviderDocsLink": "Документация",
|
||||
"ssoNoProviders": "Нет настроенных провайдеров SSO.",
|
||||
"ssoProviderName": "Отображаемое имя",
|
||||
"ssoProviderType": "Тип провайдера",
|
||||
"ssoDeleteProvider": "Удалить провайдера",
|
||||
"ssoDeleteConfirm": "Удалить провайдера? Связанные пользователи не смогут войти.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Включено",
|
||||
"ssoDisabled": "Отключено",
|
||||
"ssoSaveProvider": "Сохранить провайдера",
|
||||
"ssoTestConnection": "Проверить соединение",
|
||||
"ssoEditProvider": "Редактировать провайдера",
|
||||
"ldapHost": "LDAP-сервер",
|
||||
"ldapPort": "Порт",
|
||||
"ldapUseTls": "Использовать TLS (LDAPS)",
|
||||
"ldapBindDn": "Bind DN",
|
||||
"ldapBindPassword": "Пароль привязки",
|
||||
"ldapUserSearchBase": "База поиска пользователей",
|
||||
"ldapUserSearchFilter": "Фильтр поиска пользователей",
|
||||
"ldapUsernameAttr": "Атрибут имени пользователя",
|
||||
"ldapDisplayNameAttr": "Атрибут отображаемого имени",
|
||||
"ldapGroupSearchBase": "База поиска групп",
|
||||
"ldapAdminGroup": "Группа администраторов",
|
||||
"ldapAllowedUsers": "Разрешенные пользователи",
|
||||
"sectionUsers": "Пользователи",
|
||||
"sectionSessions": "Сессии",
|
||||
"sectionRoles": "Роли",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Документация",
|
||||
"tailscaleApiBaseUrl": "Базовый URL API",
|
||||
"tailscaleApiBaseUrlDescription": "Необязательно. Укажите собственный экземпляр Headscale вместо api.tailscale.com. Оставьте поле пустым, чтобы использовать настройки Tailscale по умолчанию.",
|
||||
"oidcDescription": "Настройка OpenID Connect для единого входа (SSO). Поля, отмеченные *, обязательны.",
|
||||
"oidcDocsLink": "Документация",
|
||||
"oidcClientId": "Client ID",
|
||||
"oidcClientSecret": "Client Secret",
|
||||
"oidcAuthUrl": "URL авторизации",
|
||||
"oidcIssuerUrl": "URL издателя",
|
||||
"oidcTokenUrl": "URL токена",
|
||||
"oidcUserIdentifier": "Путь к идентификатору пользователя",
|
||||
"oidcDisplayName": "Путь к отображаемому имени",
|
||||
"oidcScopes": "Области",
|
||||
"oidcUserinfoUrl": "Переопределить URL Userinfo",
|
||||
"oidcAllowedUsers": "Разрешенные пользователи",
|
||||
"oidcAllowedUsersDesc": "По одному email на строку. Оставьте пустым, чтобы разрешить всем.",
|
||||
"oidcAdminGroup": "Группа администраторов",
|
||||
"oidcAdminGroupDesc": "Пользователи в этой группе получают права администратора. Оставьте пустым, чтобы отключить синхронизацию групп.",
|
||||
"oidcGroupClaim": "Утверждение группы",
|
||||
"oidcGroupClaimDesc": "Необязательно. Путь к утверждению, содержащему группы пользователя. По умолчанию проверяются groups, roles, затем group. Используйте для провайдеров с настраиваемым утверждением (например, Zitadel).",
|
||||
"oidcCaCert": "Пользовательский сертификат ЦС",
|
||||
"oidcCaCertDesc": "Необязательно. Сертификат ЦС в формате PEM для провайдеров OIDC, использующих частный или самоподписанный ЦС. Оставьте пустым, чтобы использовать системное хранилище доверенных сертификатов.",
|
||||
"removeOidc": "Удалить",
|
||||
"usersCount": "{{count}} пользователей",
|
||||
"searchUsers": "Поиск пользователей...",
|
||||
"noUsers": "Нет пользователей",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Настройки Tailscale сохранены",
|
||||
"tailscaleSettingsSaveFailed": "Не удалось сохранить настройки Tailscale",
|
||||
"logLevelUpdateFailed": "Не удалось обновить уровень журналирования",
|
||||
"oidcSaved": "Конфигурация OIDC сохранена",
|
||||
"oidcSaveFailed": "Не удалось сохранить конфигурацию OIDC",
|
||||
"oidcRemoved": "Конфигурация OIDC удалена",
|
||||
"oidcRemoveFailed": "Не удалось удалить конфигурацию OIDC",
|
||||
"createUserRequired": "Имя пользователя и пароль обязательны",
|
||||
"createUserPasswordTooShort": "Пароль должен содержать не менее 6 символов",
|
||||
"createUserSuccess": "Пользователь \"{{username}}\" создан",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Спољна аутентификација још увек није подржана у апликацији Electron. Молимо вас да користите веб верзију за пријаву путем OIDC-а.",
|
||||
"loginWithProvider": "Пријавите се са {{name}}",
|
||||
"orContinueWith": "или наставите са",
|
||||
"ldapUsername": "LDAP корисничко име",
|
||||
"ldapPassword": "LDAP лозинка",
|
||||
"ldapSignIn": "Пријавите се",
|
||||
"ldapLoginFailed": "Пријављивање на LDAP није успело",
|
||||
"resetPasswordButton": "Ресетуј лозинку",
|
||||
"sendResetCode": "Пошаљи код за ресетовање",
|
||||
"resetCodeDesc": "Унесите своје корисничко име да бисте добили код за ресетовање лозинке. Код ће бити забележен у логовима Docker контејнера.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Опште",
|
||||
"sectionOidc": "ОИДЦ",
|
||||
"sectionSso": "Добављачи SSO-а",
|
||||
"ssoAddProvider": "Додај добављача",
|
||||
"ssoDocsLink": "Прикажи документе",
|
||||
"ssoProviderDocsLink": "Прикажи документе",
|
||||
"ssoNoProviders": "Није конфигурисан ниједан SSO добављач.",
|
||||
"ssoProviderName": "Приказано име",
|
||||
"ssoProviderType": "Тип добављача",
|
||||
"ssoDeleteProvider": "Обриши добављача",
|
||||
"ssoDeleteConfirm": "Да ли желите да обришете овог добављача? Повезани корисници неће моћи да се пријаве.",
|
||||
"ssoTypeOidc": "ОИДЦ",
|
||||
"ssoTypeLdap": "ЛДАП",
|
||||
"ssoTypeGithub": "ГитХаб",
|
||||
"ssoTypeGoogle": "Гугл",
|
||||
"ssoEnabled": "Омогућено",
|
||||
"ssoDisabled": "Онемогућено",
|
||||
"ssoSaveProvider": "Сачувај добављача",
|
||||
"ssoTestConnection": "Тестирање везе",
|
||||
"ssoEditProvider": "Измени добављача",
|
||||
"ldapHost": "LDAP хост",
|
||||
"ldapPort": "Лука",
|
||||
"ldapUseTls": "Користите TLS (LDAPS)",
|
||||
"ldapBindDn": "Повезивање DN-а",
|
||||
"ldapBindPassword": "Повезивање лозинке",
|
||||
"ldapUserSearchBase": "База претраге корисника",
|
||||
"ldapUserSearchFilter": "Филтер за претрагу корисника",
|
||||
"ldapUsernameAttr": "Атрибут корисничког имена",
|
||||
"ldapDisplayNameAttr": "Атрибут приказаног имена",
|
||||
"ldapGroupSearchBase": "База групне претраге",
|
||||
"ldapAdminGroup": "Администраторска група",
|
||||
"ldapAllowedUsers": "Дозвољени корисници",
|
||||
"sectionUsers": "Корисници",
|
||||
"sectionSessions": "Сесије",
|
||||
"sectionRoles": "Улоге",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Прикажи документе",
|
||||
"tailscaleApiBaseUrl": "Основни URL API-ја",
|
||||
"tailscaleApiBaseUrlDescription": "Опционо. Упутите на самостално хостовану инстанцу Headscale-а уместо api.tailscale.com. Оставите празно да бисте користили подразумевану вредност Tailscale-а.",
|
||||
"oidcDescription": "Конфигуришите OpenID Connect за SSO. Поља означена * су обавезна.",
|
||||
"oidcDocsLink": "Прикажи документе",
|
||||
"oidcClientId": "ИД клијента",
|
||||
"oidcClientSecret": "Тајна клијентска порука",
|
||||
"oidcAuthUrl": "URL за ауторизацију",
|
||||
"oidcIssuerUrl": "URL издаваоца",
|
||||
"oidcTokenUrl": "URL токена",
|
||||
"oidcUserIdentifier": "Путања идентификатора корисника",
|
||||
"oidcDisplayName": "Путања приказаног имена",
|
||||
"oidcScopes": "Опсези",
|
||||
"oidcUserinfoUrl": "Замени URL са корисничким информацијама",
|
||||
"oidcAllowedUsers": "Дозвољени корисници",
|
||||
"oidcAllowedUsersDesc": "Једна имејл адреса по реду. Оставите празно да бисте дозволили све.",
|
||||
"oidcAdminGroup": "Администраторска група",
|
||||
"oidcAdminGroupDesc": "Корисницима у овој групи је додељена администраторска позиција. Оставите празно да бисте онемогућили синхронизацију групе.",
|
||||
"oidcGroupClaim": "Групни захтев",
|
||||
"oidcGroupClaimDesc": "Опционо. Путања захтева која садржи групе корисника. Подразумевано је да су групе, улоге, па група. Користите ово за добављаче са прилагођеним захтевом (нпр. Zitadel).",
|
||||
"oidcCaCert": "Прилагођени CA сертификат",
|
||||
"oidcCaCertDesc": "Опционо. PEM-кодирани CA сертификат за OIDC добављаче који користе приватни или самопотписани CA. Оставите празно да бисте користили складиште поверења система.",
|
||||
"removeOidc": "Уклони",
|
||||
"usersCount": "{{count}} корисника",
|
||||
"searchUsers": "Претражи кориснике...",
|
||||
"noUsers": "Нема корисника",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Подешавања репске скале су сачувана",
|
||||
"tailscaleSettingsSaveFailed": "Чување подешавања Tailscale-а није успело",
|
||||
"logLevelUpdateFailed": "Није успело ажурирање нивоа евиденције",
|
||||
"oidcSaved": "OIDC конфигурација је сачувана",
|
||||
"oidcSaveFailed": "Чување OIDC конфигурације није успело",
|
||||
"oidcRemoved": "OIDC конфигурација је уклоњена",
|
||||
"oidcRemoveFailed": "Уклањање OIDC конфигурације није успело",
|
||||
"createUserRequired": "Корисничко име и лозинка су обавезни",
|
||||
"createUserPasswordTooShort": "Лозинка мора имати најмање 6 карактера",
|
||||
"createUserSuccess": "Корисник „{{username}}“ је креирао",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Extern autentisering stöds inte i Electron-appen ännu. Använd webbversionen för OIDC-inloggning.",
|
||||
"loginWithProvider": "Logga in med {{name}}",
|
||||
"orContinueWith": "eller fortsätt med",
|
||||
"ldapUsername": "LDAP-användarnamn",
|
||||
"ldapPassword": "LDAP-lösenord",
|
||||
"ldapSignIn": "Logga in",
|
||||
"ldapLoginFailed": "LDAP-inloggning misslyckades",
|
||||
"resetPasswordButton": "Återställ lösenord",
|
||||
"sendResetCode": "Skicka återställningskod",
|
||||
"resetCodeDesc": "Ange ditt användarnamn för att få en kod för återställning av lösenord. Koden kommer att loggas i docker-containerns loggfiler.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Allmän",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "SSO-leverantörer",
|
||||
"ssoAddProvider": "Lägg till leverantör",
|
||||
"ssoDocsLink": "Visa dokument",
|
||||
"ssoProviderDocsLink": "Visa dokument",
|
||||
"ssoNoProviders": "Inga SSO-leverantörer har konfigurerats.",
|
||||
"ssoProviderName": "Visningsnamn",
|
||||
"ssoProviderType": "Leverantörstyp",
|
||||
"ssoDeleteProvider": "Ta bort leverantör",
|
||||
"ssoDeleteConfirm": "Vill du ta bort den här leverantören? Associerade användare kommer inte att kunna logga in.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Aktiverad",
|
||||
"ssoDisabled": "Funktionshindrad",
|
||||
"ssoSaveProvider": "Spara leverantör",
|
||||
"ssoTestConnection": "Testa anslutning",
|
||||
"ssoEditProvider": "Redigera leverantör",
|
||||
"ldapHost": "LDAP-värd",
|
||||
"ldapPort": "Hamn",
|
||||
"ldapUseTls": "Använd TLS (LDAPS)",
|
||||
"ldapBindDn": "Bind DN",
|
||||
"ldapBindPassword": "Bind lösenord",
|
||||
"ldapUserSearchBase": "Användarsökningsbas",
|
||||
"ldapUserSearchFilter": "Användarsökningsfilter",
|
||||
"ldapUsernameAttr": "Användarnamnsattribut",
|
||||
"ldapDisplayNameAttr": "Attribut för visningsnamn",
|
||||
"ldapGroupSearchBase": "Gruppsökningsbas",
|
||||
"ldapAdminGroup": "Administratörsgrupp",
|
||||
"ldapAllowedUsers": "Tillåtna användare",
|
||||
"sectionUsers": "Användare",
|
||||
"sectionSessions": "Sessioner",
|
||||
"sectionRoles": "Roller",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Visa dokument",
|
||||
"tailscaleApiBaseUrl": "API-bas-URL",
|
||||
"tailscaleApiBaseUrlDescription": "Valfritt. Peka på en självhostad Headscale-instans istället för api.tailscale.com. Lämna tomt för att använda standardvärdet för Tailscale.",
|
||||
"oidcDescription": "Konfigurera OpenID Connect för SSO. Fält markerade med * är obligatoriska.",
|
||||
"oidcDocsLink": "Visa dokument",
|
||||
"oidcClientId": "Klient-ID",
|
||||
"oidcClientSecret": "Klienthemlighet",
|
||||
"oidcAuthUrl": "Auktoriserings-URL",
|
||||
"oidcIssuerUrl": "Utgivarens URL",
|
||||
"oidcTokenUrl": "Token-URL",
|
||||
"oidcUserIdentifier": "Användaridentifierarens sökväg",
|
||||
"oidcDisplayName": "Sökväg för visningsnamn",
|
||||
"oidcScopes": "Omfattningar",
|
||||
"oidcUserinfoUrl": "Åsidosätt användarinformations-URL",
|
||||
"oidcAllowedUsers": "Tillåtna användare",
|
||||
"oidcAllowedUsersDesc": "Ett e-postmeddelande per rad. Lämna tomt för att tillåta alla.",
|
||||
"oidcAdminGroup": "Administratörsgrupp",
|
||||
"oidcAdminGroupDesc": "Användare i den här gruppen har administratörsrättigheter. Lämna tomt för att inaktivera gruppsynkronisering.",
|
||||
"oidcGroupClaim": "Gruppanspråk",
|
||||
"oidcGroupClaimDesc": "Valfritt. Anspråkssökvägen som innehåller användarens grupper. Standardinställningen är grupper, roller och sedan grupp. Använd detta för leverantörer med ett anpassat anspråk (t.ex. Zitadel).",
|
||||
"oidcCaCert": "Anpassat CA-certifikat",
|
||||
"oidcCaCertDesc": "Valfritt. PEM-kodat CA-certifikat för OIDC-leverantörer som använder en privat eller självsignerad CA. Lämna tomt för att använda systemets förtroendearkiv.",
|
||||
"removeOidc": "Ta bort",
|
||||
"usersCount": "{{count}} användare",
|
||||
"searchUsers": "Sök användare...",
|
||||
"noUsers": "Inga användare",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Inställningar för svansskala sparade",
|
||||
"tailscaleSettingsSaveFailed": "Kunde inte spara Tailscale-inställningarna",
|
||||
"logLevelUpdateFailed": "Misslyckades med att uppdatera loggnivån",
|
||||
"oidcSaved": "OIDC-konfigurationen sparad",
|
||||
"oidcSaveFailed": "Misslyckades med att spara OIDC-konfigurationen",
|
||||
"oidcRemoved": "OIDC-konfigurationen har tagits bort",
|
||||
"oidcRemoveFailed": "Misslyckades med att ta bort OIDC-konfigurationen",
|
||||
"createUserRequired": "Användarnamn och lösenord krävs",
|
||||
"createUserPasswordTooShort": "Lösenordet måste vara minst 6 tecken långt",
|
||||
"createUserSuccess": "Användaren \"{{username}}\" skapades",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "แอป Electron ยังไม่รองรับการตรวจสอบสิทธิ์จากภายนอก โปรดใช้เวอร์ชันเว็บสำหรับการเข้าสู่ระบบ OIDC",
|
||||
"loginWithProvider": "เข้าสู่ระบบด้วย {{name}}",
|
||||
"orContinueWith": "หรือดำเนินการต่อด้วย",
|
||||
"ldapUsername": "ชื่อผู้ใช้ LDAP",
|
||||
"ldapPassword": "รหัสผ่าน LDAP",
|
||||
"ldapSignIn": "เข้าสู่ระบบ",
|
||||
"ldapLoginFailed": "การเข้าสู่ระบบ LDAP ล้มเหลว",
|
||||
"resetPasswordButton": "รีเซ็ตรหัสผ่าน",
|
||||
"sendResetCode": "ส่งรหัสรีเซ็ต",
|
||||
"resetCodeDesc": "ป้อนชื่อผู้ใช้ของคุณเพื่อรับรหัสรีเซ็ต mật khẩu รหัสจะถูกบันทึกไว้ในบันทึกของคอนเทนเนอร์ Docker",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "ทั่วไป",
|
||||
"sectionOidc": "โอไอดีซี",
|
||||
"sectionSso": "ผู้ให้บริการ SSO",
|
||||
"ssoAddProvider": "เพิ่มผู้ให้บริการ",
|
||||
"ssoDocsLink": "ดูเอกสาร",
|
||||
"ssoProviderDocsLink": "ดูเอกสาร",
|
||||
"ssoNoProviders": "ไม่ได้กำหนดค่าผู้ให้บริการ SSO ใดๆ ไว้",
|
||||
"ssoProviderName": "ชื่อที่แสดง",
|
||||
"ssoProviderType": "ประเภทผู้ให้บริการ",
|
||||
"ssoDeleteProvider": "ลบผู้ให้บริการ",
|
||||
"ssoDeleteConfirm": "ลบผู้ให้บริการนี้หรือไม่? ผู้ใช้ที่เกี่ยวข้องจะไม่สามารถเข้าสู่ระบบได้",
|
||||
"ssoTypeOidc": "โอไอดีซี",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "กิตฮับ",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "เปิดใช้งาน",
|
||||
"ssoDisabled": "พิการ",
|
||||
"ssoSaveProvider": "บันทึกผู้ให้บริการ",
|
||||
"ssoTestConnection": "ทดสอบการเชื่อมต่อ",
|
||||
"ssoEditProvider": "แก้ไขผู้ให้บริการ",
|
||||
"ldapHost": "โฮสต์ LDAP",
|
||||
"ldapPort": "ท่าเรือ",
|
||||
"ldapUseTls": "ใช้ TLS (LDAPS)",
|
||||
"ldapBindDn": "ผูก DN",
|
||||
"ldapBindPassword": "รหัสผ่านผูกมัด",
|
||||
"ldapUserSearchBase": "ฐานการค้นหาผู้ใช้",
|
||||
"ldapUserSearchFilter": "ตัวกรองการค้นหาผู้ใช้",
|
||||
"ldapUsernameAttr": "คุณลักษณะชื่อผู้ใช้",
|
||||
"ldapDisplayNameAttr": "คุณลักษณะชื่อที่แสดง",
|
||||
"ldapGroupSearchBase": "ฐานการค้นหากลุ่ม",
|
||||
"ldapAdminGroup": "กลุ่มผู้ดูแลระบบ",
|
||||
"ldapAllowedUsers": "ผู้ใช้ที่ได้รับอนุญาต",
|
||||
"sectionUsers": "ผู้ใช้",
|
||||
"sectionSessions": "การประชุม",
|
||||
"sectionRoles": "บทบาท",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "ดูเอกสาร",
|
||||
"tailscaleApiBaseUrl": "URL พื้นฐานของ API",
|
||||
"tailscaleApiBaseUrlDescription": "ตัวเลือกเสริม: ชี้ไปยังอินสแตนซ์ Headscale ที่โฮสต์เองแทน api.tailscale.com หากปล่อยว่างไว้ จะใช้ค่าเริ่มต้นของ Tailscale",
|
||||
"oidcDescription": "ตั้งค่า OpenID Connect สำหรับ SSO ช่องที่มีเครื่องหมาย * กำกับไว้เป็นช่องที่ต้องกรอก",
|
||||
"oidcDocsLink": "ดูเอกสาร",
|
||||
"oidcClientId": "รหัสลูกค้า",
|
||||
"oidcClientSecret": "ความลับของลูกค้า",
|
||||
"oidcAuthUrl": "URL การอนุญาต",
|
||||
"oidcIssuerUrl": "URL ของผู้ออก",
|
||||
"oidcTokenUrl": "URL โทเค็น",
|
||||
"oidcUserIdentifier": "เส้นทางตัวระบุผู้ใช้",
|
||||
"oidcDisplayName": "ชื่อที่แสดง เส้นทาง",
|
||||
"oidcScopes": "กล้องส่องทางไกล",
|
||||
"oidcUserinfoUrl": "แทนที่ URL ข้อมูลผู้ใช้",
|
||||
"oidcAllowedUsers": "ผู้ใช้ที่ได้รับอนุญาต",
|
||||
"oidcAllowedUsersDesc": "อีเมลหนึ่งฉบับต่อหนึ่งบรรทัด หากเว้นว่างไว้จะอนุญาตให้ใช้ทั้งหมด",
|
||||
"oidcAdminGroup": "กลุ่มผู้ดูแลระบบ",
|
||||
"oidcAdminGroupDesc": "ผู้ใช้ในกลุ่มนี้จะได้รับสิทธิ์ผู้ดูแลระบบ เว้นว่างไว้เพื่อปิดใช้งานการซิงค์กลุ่ม",
|
||||
"oidcGroupClaim": "การเรียกร้องค่าสินไหมทดแทนแบบกลุ่ม",
|
||||
"oidcGroupClaimDesc": "ตัวเลือกเสริม เส้นทางการอ้างสิทธิ์ที่ประกอบด้วยกลุ่มของผู้ใช้ ค่าเริ่มต้นคือ กลุ่ม บทบาท แล้วจึงเป็นกลุ่ม ใช้สำหรับผู้ให้บริการที่มีการอ้างสิทธิ์แบบกำหนดเอง (เช่น Zitadel)",
|
||||
"oidcCaCert": "ใบรับรอง CA แบบกำหนดเอง",
|
||||
"oidcCaCertDesc": "ตัวเลือกเสริม ใบรับรอง CA ที่เข้ารหัสแบบ PEM สำหรับผู้ให้บริการ OIDC ที่ใช้ CA ส่วนตัวหรือ CA ที่ลงนามด้วยตนเอง เว้นว่างไว้เพื่อใช้ที่เก็บใบรับรองความเชื่อถือของระบบ",
|
||||
"removeOidc": "ลบ",
|
||||
"usersCount": "ผู้ใช้ {{count}} คน",
|
||||
"searchUsers": "ค้นหาผู้ใช้...",
|
||||
"noUsers": "ไม่มีผู้ใช้",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "บันทึกการตั้งค่าเกล็ดหางแล้ว",
|
||||
"tailscaleSettingsSaveFailed": "ไม่สามารถบันทึกการตั้งค่ามาตราส่วนหางได้",
|
||||
"logLevelUpdateFailed": "ไม่สามารถอัปเดตระดับการบันทึกได้",
|
||||
"oidcSaved": "บันทึกการตั้งค่า OIDC แล้ว",
|
||||
"oidcSaveFailed": "ไม่สามารถบันทึกการตั้งค่า OIDC ได้",
|
||||
"oidcRemoved": "การกำหนดค่า OIDC ถูกลบออกแล้ว",
|
||||
"oidcRemoveFailed": "ไม่สามารถลบการตั้งค่า OIDC ได้",
|
||||
"createUserRequired": "จำเป็นต้องระบุชื่อผู้ใช้และรหัสผ่าน",
|
||||
"createUserPasswordTooShort": "รหัสผ่านต้องมีความยาวอย่างน้อย 6 ตัวอักษร",
|
||||
"createUserSuccess": "ผู้ใช้ \"{{username}}\" สร้าง",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Harici kimlik doğrulaması henüz Electron uygulamasında desteklenmiyor. OIDC ile giriş yapmak için lütfen web sürümünü kullanın.",
|
||||
"loginWithProvider": "{{name}} ile giriş yap",
|
||||
"orContinueWith": "veya şununla devam et:",
|
||||
"ldapUsername": "LDAP Kullanıcı Adı",
|
||||
"ldapPassword": "LDAP Parolası",
|
||||
"ldapSignIn": "Oturum Aç",
|
||||
"ldapLoginFailed": "LDAP oturum açma başarısız.",
|
||||
"resetPasswordButton": "Parolayı Sıfırla",
|
||||
"sendResetCode": "Sıfırlama Kodu Gönder",
|
||||
"resetCodeDesc": "Parola sıfırlama kodu almak için kullanıcı adınızı girin. Kod, docker konteyner günlüklerine yazılacak.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Genel",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "SSO Sağlayıcıları",
|
||||
"ssoAddProvider": "Sağlayıcı Ekle",
|
||||
"ssoDocsLink": "Belgeleri görüntüle",
|
||||
"ssoProviderDocsLink": "Belgeleri görüntüle",
|
||||
"ssoNoProviders": "Hiçbir SSO sağlayıcısı yapılandırılmamış.",
|
||||
"ssoProviderName": "Görünen Ad",
|
||||
"ssoProviderType": "Sağlayıcı Türü",
|
||||
"ssoDeleteProvider": "Sağlayıcıyı Sil",
|
||||
"ssoDeleteConfirm": "Bu sağlayıcı silinsin mi? İlişkili kullanıcılar oturum açamaz.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Etkin",
|
||||
"ssoDisabled": "Devre Dışı",
|
||||
"ssoSaveProvider": "Sağlayıcıyı Kaydet",
|
||||
"ssoTestConnection": "Bağlantıyı Test Et",
|
||||
"ssoEditProvider": "Sağlayıcıyı Düzenle",
|
||||
"ldapHost": "LDAP Sunucusu",
|
||||
"ldapPort": "Port",
|
||||
"ldapUseTls": "TLS Kullan (LDAPS)",
|
||||
"ldapBindDn": "Bind DN",
|
||||
"ldapBindPassword": "Bind Parolası",
|
||||
"ldapUserSearchBase": "Kullanıcı Arama Tabanı",
|
||||
"ldapUserSearchFilter": "Kullanıcı Arama Süzgeci",
|
||||
"ldapUsernameAttr": "Kullanıcı Adı Özniteliği",
|
||||
"ldapDisplayNameAttr": "Görünen Ad Özniteliği",
|
||||
"ldapGroupSearchBase": "Grup Arama Tabanı",
|
||||
"ldapAdminGroup": "Yönetici Grubu",
|
||||
"ldapAllowedUsers": "İzin Verilen Kullanıcılar",
|
||||
"sectionUsers": "Kullanıcılar",
|
||||
"sectionSessions": "Oturumlar",
|
||||
"sectionRoles": "Roller",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Belgeleri görüntüle",
|
||||
"tailscaleApiBaseUrl": "API Temel URL'si",
|
||||
"tailscaleApiBaseUrlDescription": "İsteğe bağlı. api.tailscale.com yerine kendi sunucunuzda barındırdığınız bir Headscale örneğine işaret edin. Tailscale varsayılanını kullanmak için boş bırakın.",
|
||||
"oidcDescription": "SSO için OpenID Connect yapılandırması. * ile işaretli alanlar zorunludur.",
|
||||
"oidcDocsLink": "Belgeleri görüntüle",
|
||||
"oidcClientId": "İstemci Kimliği",
|
||||
"oidcClientSecret": "İstemci Sırrı",
|
||||
"oidcAuthUrl": "Yetkilendirme URL'si",
|
||||
"oidcIssuerUrl": "Sağlayıcı URL'si",
|
||||
"oidcTokenUrl": "Belirteç URL'si",
|
||||
"oidcUserIdentifier": "Kullanıcı Tanımlayıcı Yolu",
|
||||
"oidcDisplayName": "Görünen Ad Yolu",
|
||||
"oidcScopes": "Kapsamlar",
|
||||
"oidcUserinfoUrl": "Userinfo URL'sini Geçersiz Kıl",
|
||||
"oidcAllowedUsers": "İzin Verilen Kullanıcılar",
|
||||
"oidcAllowedUsersDesc": "Her satıra bir e-posta. Tümüne izin vermek için boş bırakın.",
|
||||
"oidcAdminGroup": "Yönetici Grubu",
|
||||
"oidcAdminGroupDesc": "Bu gruptaki kullanıcılara yönetici yetkisi verilir. Grup eşitlemeyi devre dışı bırakmak için boş bırakın.",
|
||||
"oidcGroupClaim": "Grup Talebi",
|
||||
"oidcGroupClaimDesc": "İsteğe bağlı. Kullanıcının gruplarını içeren talep yolu. Varsayılan olarak groups, roles, ardından group kullanılır. Özel talep kullanan sağlayıcılar için bunu kullanın (örn. Zitadel).",
|
||||
"oidcCaCert": "Özel CA Sertifikası",
|
||||
"oidcCaCertDesc": "İsteğe bağlı. Özel veya kendinden imzalı CA kullanan OIDC sağlayıcıları için PEM kodlu CA sertifikası. Sistem güven deposunu kullanmak için boş bırakın.",
|
||||
"removeOidc": "Kaldır",
|
||||
"usersCount": "{{count}} kullanıcı",
|
||||
"searchUsers": "Kullanıcıları ara...",
|
||||
"noUsers": "Kullanıcı yok",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Tailscale ayarları kaydedildi",
|
||||
"tailscaleSettingsSaveFailed": "Tailscale ayarları kaydedilemedi",
|
||||
"logLevelUpdateFailed": "Günlük seviyesi güncellenemedi",
|
||||
"oidcSaved": "OIDC yapılandırması kaydedildi",
|
||||
"oidcSaveFailed": "OIDC yapılandırması kaydedilemedi",
|
||||
"oidcRemoved": "OIDC yapılandırması kaldırıldı",
|
||||
"oidcRemoveFailed": "OIDC yapılandırması kaldırılamadı",
|
||||
"createUserRequired": "Kullanıcı adı ve parola zorunludur",
|
||||
"createUserPasswordTooShort": "Parola en az 6 karakter olmalıdır",
|
||||
"createUserSuccess": "Kullanıcı \"{{username}}\" oluşturuldu",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Зовнішня автентифікація поки що не підтримується в застосунку Electron. Будь ласка, використовуйте веб-версію для входу в OIDC.",
|
||||
"loginWithProvider": "Увійти за допомогою {{name}}",
|
||||
"orContinueWith": "або продовжити з",
|
||||
"ldapUsername": "Ім'я користувача LDAP",
|
||||
"ldapPassword": "Пароль LDAP",
|
||||
"ldapSignIn": "Увійти",
|
||||
"ldapLoginFailed": "Не вдалося ввійти до LDAP",
|
||||
"resetPasswordButton": "Скинути пароль",
|
||||
"sendResetCode": "Надіслати код скидання",
|
||||
"resetCodeDesc": "Введіть своє ім'я користувача, щоб отримати код для скидання пароля. Код буде записано в журнали контейнера Docker.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Загальне",
|
||||
"sectionOidc": "ОІДК",
|
||||
"sectionSso": "Постачальники єдиного входу (SSO)",
|
||||
"ssoAddProvider": "Додати постачальника",
|
||||
"ssoDocsLink": "Переглянути документи",
|
||||
"ssoProviderDocsLink": "Переглянути документи",
|
||||
"ssoNoProviders": "Постачальників SSO не налаштовано.",
|
||||
"ssoProviderName": "Відображуване ім'я",
|
||||
"ssoProviderType": "Тип постачальника",
|
||||
"ssoDeleteProvider": "Видалити постачальника",
|
||||
"ssoDeleteConfirm": "Видалити цього постачальника? Пов’язані користувачі не зможуть увійти.",
|
||||
"ssoTypeOidc": "ОІДК",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Увімкнено",
|
||||
"ssoDisabled": "Інвалід",
|
||||
"ssoSaveProvider": "Зберегти постачальника",
|
||||
"ssoTestConnection": "Тестове з'єднання",
|
||||
"ssoEditProvider": "Редагувати постачальника",
|
||||
"ldapHost": "Хост LDAP",
|
||||
"ldapPort": "Порт",
|
||||
"ldapUseTls": "Використовувати TLS (LDAPS)",
|
||||
"ldapBindDn": "Прив'язане DN",
|
||||
"ldapBindPassword": "Прив’язати пароль",
|
||||
"ldapUserSearchBase": "База пошуку користувачів",
|
||||
"ldapUserSearchFilter": "Фільтр пошуку користувачів",
|
||||
"ldapUsernameAttr": "Атрибут імені користувача",
|
||||
"ldapDisplayNameAttr": "Атрибут відображуваного імені",
|
||||
"ldapGroupSearchBase": "База групового пошуку",
|
||||
"ldapAdminGroup": "Група адміністраторів",
|
||||
"ldapAllowedUsers": "Дозволені користувачі",
|
||||
"sectionUsers": "Користувачі",
|
||||
"sectionSessions": "Сесії",
|
||||
"sectionRoles": "Ролі",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Переглянути документи",
|
||||
"tailscaleApiBaseUrl": "Базова URL-адреса API",
|
||||
"tailscaleApiBaseUrlDescription": "Необов’язково. Вкажіть на власний екземпляр Headscale замість api.tailscale.com. Залиште поле порожнім, щоб використовувати значення Tailscale за замовчуванням.",
|
||||
"oidcDescription": "Налаштуйте OpenID Connect для єдиного входу. Поля, позначені *, обов’язкові для заповнення.",
|
||||
"oidcDocsLink": "Переглянути документи",
|
||||
"oidcClientId": "Ідентифікатор клієнта",
|
||||
"oidcClientSecret": "Секрет клієнта",
|
||||
"oidcAuthUrl": "URL-адреса авторизації",
|
||||
"oidcIssuerUrl": "URL-адреса емітента",
|
||||
"oidcTokenUrl": "URL-адреса токена",
|
||||
"oidcUserIdentifier": "Шлях ідентифікатора користувача",
|
||||
"oidcDisplayName": "Шлях до відображуваного імені",
|
||||
"oidcScopes": "Області застосування",
|
||||
"oidcUserinfoUrl": "Перевизначити URL-адресу інформації користувача",
|
||||
"oidcAllowedUsers": "Дозволені користувачі",
|
||||
"oidcAllowedUsersDesc": "Одна електронна адреса на рядок. Залиште поле порожнім, щоб дозволити всім.",
|
||||
"oidcAdminGroup": "Група адміністраторів",
|
||||
"oidcAdminGroupDesc": "Користувачам у цій групі надано права адміністратора. Залиште поле порожнім, щоб вимкнути синхронізацію групи.",
|
||||
"oidcGroupClaim": "Групова претензія",
|
||||
"oidcGroupClaimDesc": "Необов'язково. Шлях до заяви, що містить групи користувача. За замовчуванням використовуються групи, ролі, а потім група. Використовуйте це для постачальників із власною заявою (наприклад, Zitadel).",
|
||||
"oidcCaCert": "Спеціальний сертифікат CA",
|
||||
"oidcCaCertDesc": "Необов’язково. Сертифікат CA з PEM-кодуванням для постачальників OIDC, які використовують приватний або самопідписаний CA. Залиште поле порожнім, щоб використовувати системне сховище довірених сертифікатів.",
|
||||
"removeOidc": "Видалити",
|
||||
"usersCount": "{{count}} користувачів",
|
||||
"searchUsers": "Пошук користувачів...",
|
||||
"noUsers": "Немає користувачів",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Налаштування хвостової шкали збережено",
|
||||
"tailscaleSettingsSaveFailed": "Не вдалося зберегти налаштування Tailscale",
|
||||
"logLevelUpdateFailed": "Не вдалося оновити рівень журналу",
|
||||
"oidcSaved": "Конфігурацію OIDC збережено",
|
||||
"oidcSaveFailed": "Не вдалося зберегти конфігурацію OIDC",
|
||||
"oidcRemoved": "Конфігурацію OIDC видалено",
|
||||
"oidcRemoveFailed": "Не вдалося видалити конфігурацію OIDC",
|
||||
"createUserRequired": "Потрібні ім'я користувача та пароль",
|
||||
"createUserPasswordTooShort": "Пароль має містити щонайменше 6 символів",
|
||||
"createUserSuccess": "Користувач «{{username}}» створено",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Xác thực bên ngoài chưa được hỗ trợ trong ứng dụng Electron. Vui lòng sử dụng phiên bản web để đăng nhập OIDC.",
|
||||
"loginWithProvider": "Đăng nhập bằng {{name}}",
|
||||
"orContinueWith": "hoặc tiếp tục với",
|
||||
"ldapUsername": "Tên người dùng LDAP",
|
||||
"ldapPassword": "Mật khẩu LDAP",
|
||||
"ldapSignIn": "Đăng nhập",
|
||||
"ldapLoginFailed": "Đăng nhập LDAP thất bại",
|
||||
"resetPasswordButton": "Đặt lại mật khẩu",
|
||||
"sendResetCode": "Gửi mã đặt lại",
|
||||
"resetCodeDesc": "Nhập tên người dùng của bạn để nhận mã đặt lại mật khẩu. Mã sẽ được ghi trong nhật ký container Docker.",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "Chung",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "Nhà cung cấp SSO",
|
||||
"ssoAddProvider": "Thêm nhà cung cấp",
|
||||
"ssoDocsLink": "Xem tài liệu",
|
||||
"ssoProviderDocsLink": "Xem tài liệu",
|
||||
"ssoNoProviders": "Chưa có nhà cung cấp SSO.",
|
||||
"ssoProviderName": "Tên hiển thị",
|
||||
"ssoProviderType": "Loại nhà cung cấp",
|
||||
"ssoDeleteProvider": "Xóa nhà cung cấp",
|
||||
"ssoDeleteConfirm": "Xóa nhà cung cấp này? Người dùng liên kết sẽ không thể đăng nhập.",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "Đã bật",
|
||||
"ssoDisabled": "Đã tắt",
|
||||
"ssoSaveProvider": "Lưu nhà cung cấp",
|
||||
"ssoTestConnection": "Kiểm tra kết nối",
|
||||
"ssoEditProvider": "Chỉnh sửa nhà cung cấp",
|
||||
"ldapHost": "Máy chủ LDAP",
|
||||
"ldapPort": "Cổng",
|
||||
"ldapUseTls": "Dùng TLS (LDAPS)",
|
||||
"ldapBindDn": "Bind DN",
|
||||
"ldapBindPassword": "Mật khẩu Bind",
|
||||
"ldapUserSearchBase": "Cơ sở tìm kiếm người dùng",
|
||||
"ldapUserSearchFilter": "Bộ lọc tìm kiếm người dùng",
|
||||
"ldapUsernameAttr": "Thuộc tính tên người dùng",
|
||||
"ldapDisplayNameAttr": "Thuộc tính tên hiển thị",
|
||||
"ldapGroupSearchBase": "Cơ sở tìm kiếm nhóm",
|
||||
"ldapAdminGroup": "Nhóm quản trị",
|
||||
"ldapAllowedUsers": "Người dùng được phép",
|
||||
"sectionUsers": "Người dùng",
|
||||
"sectionSessions": "Phiên",
|
||||
"sectionRoles": "Vai trò",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "Xem tài liệu",
|
||||
"tailscaleApiBaseUrl": "URL cơ sở của API",
|
||||
"tailscaleApiBaseUrlDescription": "Tùy chọn. Trỏ đến một phiên bản Headscale tự lưu trữ thay vì api.tailscale.com. Để trống để sử dụng mặc định của Tailscale.",
|
||||
"oidcDescription": "Cấu hình OpenID Connect cho SSO. Các trường đánh dấu * là bắt buộc.",
|
||||
"oidcDocsLink": "Xem tài liệu",
|
||||
"oidcClientId": "Client ID",
|
||||
"oidcClientSecret": "Client Secret",
|
||||
"oidcAuthUrl": "URL ủy quyền",
|
||||
"oidcIssuerUrl": "Issuer URL",
|
||||
"oidcTokenUrl": "Token URL",
|
||||
"oidcUserIdentifier": "Đường dẫn định danh người dùng",
|
||||
"oidcDisplayName": "Đường dẫn tên hiển thị",
|
||||
"oidcScopes": "Phạm vi",
|
||||
"oidcUserinfoUrl": "Ghi đè URL Userinfo",
|
||||
"oidcAllowedUsers": "Người dùng được phép",
|
||||
"oidcAllowedUsersDesc": "Mỗi email một dòng. Để trống để cho phép tất cả.",
|
||||
"oidcAdminGroup": "Nhóm Admin",
|
||||
"oidcAdminGroupDesc": "Người dùng trong nhóm này được cấp quyền admin. Để trống để tắt đồng bộ nhóm.",
|
||||
"oidcGroupClaim": "Group Claim",
|
||||
"oidcGroupClaimDesc": "Tùy chọn. Đường dẫn claim chứa các nhóm của người dùng. Mặc định là groups, roles, rồi group. Sử dụng cho các nhà cung cấp có claim tùy chỉnh (ví dụ: Zitadel).",
|
||||
"oidcCaCert": "Chứng chỉ CA tùy chỉnh",
|
||||
"oidcCaCertDesc": "Tùy chọn. Chứng chỉ CA mã hóa PEM cho các nhà cung cấp OIDC sử dụng CA riêng hoặc tự ký. Để trống để sử dụng kho tin cậy hệ thống.",
|
||||
"removeOidc": "Xóa",
|
||||
"usersCount": "{{count}} người dùng",
|
||||
"searchUsers": "Tìm kiếm người dùng...",
|
||||
"noUsers": "Không có người dùng",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Cài đặt Tailscale đã được lưu",
|
||||
"tailscaleSettingsSaveFailed": "Lưu cài đặt Tailscale thất bại",
|
||||
"logLevelUpdateFailed": "Cập nhật mức ghi nhật ký thất bại",
|
||||
"oidcSaved": "Cấu hình OIDC đã được lưu",
|
||||
"oidcSaveFailed": "Lưu cấu hình OIDC thất bại",
|
||||
"oidcRemoved": "Cấu hình OIDC đã được gỡ bỏ",
|
||||
"oidcRemoveFailed": "Gỡ bỏ cấu hình OIDC thất bại",
|
||||
"createUserRequired": "Tên đăng nhập và mật khẩu là bắt buộc",
|
||||
"createUserPasswordTooShort": "Mật khẩu phải có ít nhất 6 ký tự",
|
||||
"createUserSuccess": "Người dùng \"{{username}}\" đã được tạo",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Electron应用尚不支持外部身份验证。请使用Web版本进行OIDC登录。",
|
||||
"loginWithProvider": "使用{{name}}登录",
|
||||
"orContinueWith": "或继续使用",
|
||||
"ldapUsername": "LDAP 用户名",
|
||||
"ldapPassword": "LDAP 密码",
|
||||
"ldapSignIn": "登录",
|
||||
"ldapLoginFailed": "LDAP登录失败",
|
||||
"resetPasswordButton": "重置密码",
|
||||
"sendResetCode": "发送重置验证码",
|
||||
"resetCodeDesc": "输入用户名以接收密码重置验证码。验证码将记录在Docker容器日志中。",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "通用",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "SSO 提供商",
|
||||
"ssoAddProvider": "添加提供商",
|
||||
"ssoDocsLink": "查看文档",
|
||||
"ssoProviderDocsLink": "查看文档",
|
||||
"ssoNoProviders": "未配置 SSO 提供商。",
|
||||
"ssoProviderName": "显示名称",
|
||||
"ssoProviderType": "提供商类型",
|
||||
"ssoDeleteProvider": "删除提供商",
|
||||
"ssoDeleteConfirm": "删除此提供商?关联用户将无法登录。",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "已启用",
|
||||
"ssoDisabled": "已禁用",
|
||||
"ssoSaveProvider": "保存提供商",
|
||||
"ssoTestConnection": "测试连接",
|
||||
"ssoEditProvider": "编辑提供商",
|
||||
"ldapHost": "LDAP 主机",
|
||||
"ldapPort": "端口",
|
||||
"ldapUseTls": "使用 TLS (LDAPS)",
|
||||
"ldapBindDn": "绑定 DN",
|
||||
"ldapBindPassword": "绑定密码",
|
||||
"ldapUserSearchBase": "用户搜索基准",
|
||||
"ldapUserSearchFilter": "用户搜索过滤器",
|
||||
"ldapUsernameAttr": "用户名属性",
|
||||
"ldapDisplayNameAttr": "显示名称属性",
|
||||
"ldapGroupSearchBase": "群组搜索基准",
|
||||
"ldapAdminGroup": "管理员群组",
|
||||
"ldapAllowedUsers": "允许的用户",
|
||||
"sectionUsers": "用户",
|
||||
"sectionSessions": "会话",
|
||||
"sectionRoles": "角色",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "查看文档",
|
||||
"tailscaleApiBaseUrl": "API 基本 URL",
|
||||
"tailscaleApiBaseUrlDescription": "可选。指向自托管的 Headscale 实例,而不是 api.tailscale.com。留空则使用 Tailscale 默认值。",
|
||||
"oidcDescription": "为 SSO 配置 OpenID Connect。带 * 的字段为必填项。",
|
||||
"oidcDocsLink": "查看文档",
|
||||
"oidcClientId": "客户端 ID",
|
||||
"oidcClientSecret": "客户端密钥",
|
||||
"oidcAuthUrl": "授权 URL",
|
||||
"oidcIssuerUrl": "颁发者 URL",
|
||||
"oidcTokenUrl": "令牌 URL",
|
||||
"oidcUserIdentifier": "用户标识符路径",
|
||||
"oidcDisplayName": "显示名称路径",
|
||||
"oidcScopes": "作用域",
|
||||
"oidcUserinfoUrl": "覆盖 Userinfo URL",
|
||||
"oidcAllowedUsers": "允许的用户",
|
||||
"oidcAllowedUsersDesc": "每行一个邮箱。留空表示允许所有用户。",
|
||||
"oidcAdminGroup": "管理员组",
|
||||
"oidcAdminGroupDesc": "此组的用户将被授予管理员权限。留空可禁用组同步。",
|
||||
"oidcGroupClaim": "组声明",
|
||||
"oidcGroupClaimDesc": "可选。包含用户组的声明路径。默认依次使用 groups、roles、group。用于具有自定义声明的提供商(如 Zitadel)。",
|
||||
"oidcCaCert": "自定义 CA 证书",
|
||||
"oidcCaCertDesc": "可选。PEM 编码的 CA 证书,用于使用私有或自签名 CA 的 OIDC 提供商。留空则使用系统信任存储。",
|
||||
"removeOidc": "移除",
|
||||
"usersCount": "{{count}} 个用户",
|
||||
"searchUsers": "搜索用户...",
|
||||
"noUsers": "没有用户",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Tailscale 设置已保存",
|
||||
"tailscaleSettingsSaveFailed": "保存 Tailscale 设置失败",
|
||||
"logLevelUpdateFailed": "更新日志级别失败",
|
||||
"oidcSaved": "OIDC 配置已保存",
|
||||
"oidcSaveFailed": "保存 OIDC 配置失败",
|
||||
"oidcRemoved": "OIDC 配置已移除",
|
||||
"oidcRemoveFailed": "移除 OIDC 配置失败",
|
||||
"createUserRequired": "需要用户名和密码",
|
||||
"createUserPasswordTooShort": "密码长度至少需要 6 个字符",
|
||||
"createUserSuccess": "用户 \"{{username}}\" 已创建",
|
||||
|
||||
@@ -1614,10 +1614,6 @@
|
||||
"externalNotSupportedInElectron": "Electron 應用程式尚不支援外部驗證。請使用網頁版進行 OIDC 登入。",
|
||||
"loginWithProvider": "使用 {{name}} 登入",
|
||||
"orContinueWith": "或使用以下方式繼續",
|
||||
"ldapUsername": "LDAP 使用者名稱",
|
||||
"ldapPassword": "LDAP 密碼",
|
||||
"ldapSignIn": "登入",
|
||||
"ldapLoginFailed": "LDAP 登入失敗",
|
||||
"resetPasswordButton": "重設密碼",
|
||||
"sendResetCode": "傳送重設代碼",
|
||||
"resetCodeDesc": "輸入您的使用者名稱以接收密碼重設代碼。代碼將記錄在 Docker 容器記錄中。",
|
||||
@@ -1783,37 +1779,6 @@
|
||||
},
|
||||
"admin": {
|
||||
"sectionGeneral": "一般",
|
||||
"sectionOidc": "OIDC",
|
||||
"sectionSso": "SSO 提供者",
|
||||
"ssoAddProvider": "新增提供者",
|
||||
"ssoDocsLink": "檢視文件",
|
||||
"ssoProviderDocsLink": "檢視文件",
|
||||
"ssoNoProviders": "尚未設定 SSO 提供者。",
|
||||
"ssoProviderName": "顯示名稱",
|
||||
"ssoProviderType": "提供者類型",
|
||||
"ssoDeleteProvider": "刪除提供者",
|
||||
"ssoDeleteConfirm": "刪除此提供者?相關使用者將無法登入。",
|
||||
"ssoTypeOidc": "OIDC",
|
||||
"ssoTypeLdap": "LDAP",
|
||||
"ssoTypeGithub": "GitHub",
|
||||
"ssoTypeGoogle": "Google",
|
||||
"ssoEnabled": "已啟用",
|
||||
"ssoDisabled": "已停用",
|
||||
"ssoSaveProvider": "儲存提供者",
|
||||
"ssoTestConnection": "測試連線",
|
||||
"ssoEditProvider": "編輯提供者",
|
||||
"ldapHost": "LDAP 主機",
|
||||
"ldapPort": "連接埠",
|
||||
"ldapUseTls": "使用 TLS (LDAPS)",
|
||||
"ldapBindDn": "綁定 DN",
|
||||
"ldapBindPassword": "綁定密碼",
|
||||
"ldapUserSearchBase": "使用者搜尋基底",
|
||||
"ldapUserSearchFilter": "使用者搜尋篩選器",
|
||||
"ldapUsernameAttr": "使用者名稱屬性",
|
||||
"ldapDisplayNameAttr": "顯示名稱屬性",
|
||||
"ldapGroupSearchBase": "群組搜尋基底",
|
||||
"ldapAdminGroup": "管理員群組",
|
||||
"ldapAllowedUsers": "允許的使用者",
|
||||
"sectionUsers": "使用者",
|
||||
"sectionSessions": "工作階段",
|
||||
"sectionRoles": "角色",
|
||||
@@ -1927,26 +1892,6 @@
|
||||
"tailscaleApiKeyDocsLink": "檢視文件",
|
||||
"tailscaleApiBaseUrl": "API 基本 URL",
|
||||
"tailscaleApiBaseUrlDescription": "可選。指向自架的 Headscale 實例,而不是 api.tailscale.com。留空則使用 Tailscale 預設值。",
|
||||
"oidcDescription": "設定 OpenID Connect 以進行單一登入 (SSO)。標記 * 的欄位為必填。",
|
||||
"oidcDocsLink": "檢視文件",
|
||||
"oidcClientId": "用戶端 ID",
|
||||
"oidcClientSecret": "用戶端密鑰",
|
||||
"oidcAuthUrl": "授權 URL",
|
||||
"oidcIssuerUrl": "發行者 URL",
|
||||
"oidcTokenUrl": "權杖 URL",
|
||||
"oidcUserIdentifier": "使用者識別碼路徑",
|
||||
"oidcDisplayName": "顯示名稱路徑",
|
||||
"oidcScopes": "範圍",
|
||||
"oidcUserinfoUrl": "覆寫使用者資訊 URL",
|
||||
"oidcAllowedUsers": "允許的使用者",
|
||||
"oidcAllowedUsersDesc": "每行一個電子郵件。留空以允許全部。",
|
||||
"oidcAdminGroup": "管理員群組",
|
||||
"oidcAdminGroupDesc": "此群組中的使用者將被授予管理員權限。留空以停用群組同步。",
|
||||
"oidcGroupClaim": "群組宣告",
|
||||
"oidcGroupClaimDesc": "選用。包含使用者群組的宣告路徑。預設為 groups、roles、group。用於自訂宣告的提供者(例如 Zitadel)。",
|
||||
"oidcCaCert": "自訂 CA 憑證",
|
||||
"oidcCaCertDesc": "選用。PEM 編碼的 CA 憑證,用於使用私有或自簽 CA 的 OIDC 提供者。留空以使用系統信任儲存區。",
|
||||
"removeOidc": "移除",
|
||||
"usersCount": "{{count}} 位使用者",
|
||||
"searchUsers": "搜尋用戶...",
|
||||
"noUsers": "沒有用戶",
|
||||
@@ -2076,10 +2021,6 @@
|
||||
"tailscaleSettingsSaved": "Tailscale 設定已儲存",
|
||||
"tailscaleSettingsSaveFailed": "無法儲存 Tailscale 設定",
|
||||
"logLevelUpdateFailed": "無法更新記錄層級",
|
||||
"oidcSaved": "OIDC 設定已儲存",
|
||||
"oidcSaveFailed": "無法儲存 OIDC 設定",
|
||||
"oidcRemoved": "OIDC 設定已移除",
|
||||
"oidcRemoveFailed": "無法移除 OIDC 設定",
|
||||
"createUserRequired": "需要使用者名稱與密碼",
|
||||
"createUserPasswordTooShort": "密碼長度至少需要 6 個字元",
|
||||
"createUserSuccess": "已建立使用者「{{username}}」",
|
||||
|
||||
@@ -60,7 +60,6 @@ import {
|
||||
type LogContext,
|
||||
} from "@/lib/frontend-logger";
|
||||
import { dbHealthMonitor } from "@/lib/db-health-monitor";
|
||||
import { asHttpError } from "@/lib/http-error";
|
||||
import { getDeviceId } from "@/lib/device-id";
|
||||
|
||||
export type ServerStatus = {
|
||||
@@ -109,10 +108,6 @@ interface UserCount {
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface OIDCAuthorize {
|
||||
auth_url: string;
|
||||
}
|
||||
|
||||
type ElectronApi = {
|
||||
isElectron?: boolean;
|
||||
getSetting?: (key: string) => Promise<string | null | undefined>;
|
||||
@@ -1561,29 +1556,6 @@ export async function getPasswordLoginAllowed(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOIDCConfig(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/users/oidc-config");
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
const httpError = asHttpError(error);
|
||||
console.warn(
|
||||
"Failed to fetch OIDC config:",
|
||||
httpError.response?.data?.error || httpError.message,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAdminOIDCConfig(): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
const response = await authApi.get("/users/oidc-config/admin");
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "fetch admin OIDC config");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSetupRequired(): Promise<{ setup_required: boolean }> {
|
||||
try {
|
||||
const response = await authApi.get("/users/setup-required");
|
||||
@@ -1720,22 +1692,6 @@ export async function changePassword(oldPassword: string, newPassword: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getOIDCAuthorizeUrl(
|
||||
rememberMe = false,
|
||||
desktopCallbackPort?: number,
|
||||
providerId?: number,
|
||||
appCallbackUrl?: string,
|
||||
): Promise<OIDCAuthorize> {
|
||||
try {
|
||||
const response = await authApi.get("/users/oidc/authorize", {
|
||||
params: { rememberMe, desktopCallbackPort, providerId, appCallbackUrl },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
handleApiError(error, "get OIDC authorize URL");
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
export {
|
||||
getUserList,
|
||||
@@ -1759,8 +1715,6 @@ export {
|
||||
updatePasswordLoginAllowed,
|
||||
getPasswordResetAllowed,
|
||||
updatePasswordResetAllowed,
|
||||
updateOIDCConfig,
|
||||
disableOIDCConfig,
|
||||
adminResetUserPassword,
|
||||
adminExportUserData,
|
||||
type ApiKey,
|
||||
|
||||
@@ -57,12 +57,6 @@ import {
|
||||
uploadManualSslCertificate,
|
||||
type AcmeSettings,
|
||||
} from "@/api/acme-ssl-api";
|
||||
import {
|
||||
getAdminSSOProviders,
|
||||
updateSSOProvider,
|
||||
deleteSSOProvider,
|
||||
} from "@/api/sso-provider-api";
|
||||
import type { SSOProvider } from "@/types/index";
|
||||
import {
|
||||
type ApiKey,
|
||||
type CreatedApiKey,
|
||||
@@ -85,10 +79,8 @@ import {
|
||||
AdminDatabaseSection,
|
||||
AdminGeneralSettingsSection,
|
||||
AdminHostDefaultsSection,
|
||||
AdminSSOSection,
|
||||
AdminSSLSection,
|
||||
} from "./AdminSettingsSections";
|
||||
import { SSOProviderDialog } from "./SSOProviderDialog";
|
||||
import { AdminApiKeysSection } from "./AdminApiKeysSection";
|
||||
import { AdminAuditLogSection } from "./AdminAuditLogSection";
|
||||
import {
|
||||
@@ -168,15 +160,11 @@ export function AdminSettingsPanel({
|
||||
const [brandingSaving, setBrandingSaving] = useState(false);
|
||||
const { applyBranding } = useBranding();
|
||||
|
||||
// SSO / auto-provision state
|
||||
// External login state
|
||||
const [oidcAutoProvision, setOidcAutoProvision] = useState(false);
|
||||
const [secondFactorAfterExternalLogin, setSecondFactorAfterExternalLogin] =
|
||||
useState(false);
|
||||
const [oidcSilentLoginDefault, setOidcSilentLoginDefault] = useState(false);
|
||||
const [ssoProviders, setSsoProviders] = useState<SSOProvider[]>([]);
|
||||
const [ssoDialogOpen, setSsoDialogOpen] = useState(false);
|
||||
const [ssoDialogProvider, setSsoDialogProvider] =
|
||||
useState<SSOProvider | null>(null);
|
||||
|
||||
// Create user dialog
|
||||
const [createUserOpen, setCreateUserOpen] = useState(false);
|
||||
@@ -259,7 +247,6 @@ export function AdminSettingsPanel({
|
||||
loadRoles();
|
||||
loadApiKeys();
|
||||
loadGeneralSettings();
|
||||
loadSSOProviders();
|
||||
}, []);
|
||||
|
||||
// Debounced so typing in the search box does not fire a request per keystroke.
|
||||
@@ -414,15 +401,6 @@ export function AdminSettingsPanel({
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function loadSSOProviders() {
|
||||
try {
|
||||
const providers = await getAdminSSOProviders();
|
||||
setSsoProviders(providers);
|
||||
} catch {
|
||||
// non-fatal
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(id: AdminSection) {
|
||||
setOpenSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -627,43 +605,6 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
function handleAddProvider() {
|
||||
setSsoDialogProvider(null);
|
||||
setSsoDialogOpen(true);
|
||||
}
|
||||
|
||||
function handleEditProvider(provider: SSOProvider) {
|
||||
setSsoDialogProvider(provider);
|
||||
setSsoDialogOpen(true);
|
||||
}
|
||||
|
||||
async function handleDeleteProvider(id: number) {
|
||||
if (!window.confirm(t("admin.ssoDeleteConfirm"))) return;
|
||||
try {
|
||||
await deleteSSOProvider(id);
|
||||
setSsoProviders((prev) => prev.filter((p) => p.id !== id));
|
||||
toast.success(t("common.deleted"));
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e, t("common.deleteFailed")));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleProviderEnabled(id: number, enabled: boolean) {
|
||||
const provider = ssoProviders.find((p) => p.id === id);
|
||||
if (!provider) return;
|
||||
setSsoProviders((prev) =>
|
||||
prev.map((p) => (p.id === id ? { ...p, enabled } : p)),
|
||||
);
|
||||
try {
|
||||
await updateSSOProvider(id, { enabled });
|
||||
} catch (e) {
|
||||
setSsoProviders((prev) =>
|
||||
prev.map((p) => (p.id === id ? { ...p, enabled: !enabled } : p)),
|
||||
);
|
||||
toast.error(apiErrorMessage(e, t("common.saveFailed")));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAcmeSettings() {
|
||||
try {
|
||||
const payload: Parameters<typeof updateAcmeSslSettings>[0] = {
|
||||
@@ -736,18 +677,6 @@ export function AdminSettingsPanel({
|
||||
}
|
||||
}
|
||||
|
||||
function handleProviderSaved(saved: SSOProvider) {
|
||||
setSsoProviders((prev) => {
|
||||
const idx = prev.findIndex((p) => p.id === saved.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = saved;
|
||||
return next;
|
||||
}
|
||||
return [...prev, saved];
|
||||
});
|
||||
}
|
||||
|
||||
async function handleCreateUser() {
|
||||
if (!newUsername.trim() || !newPassword.trim()) {
|
||||
toast.error(t("admin.createUserRequired"));
|
||||
@@ -1043,23 +972,6 @@ export function AdminSettingsPanel({
|
||||
handleSaveLogLevel={handleSaveLogLevel}
|
||||
/>
|
||||
|
||||
<AdminSSOSection
|
||||
open={openSections.has("sso")}
|
||||
onToggle={() => toggle("sso")}
|
||||
providers={ssoProviders}
|
||||
onAddProvider={handleAddProvider}
|
||||
onEditProvider={handleEditProvider}
|
||||
onDeleteProvider={handleDeleteProvider}
|
||||
onToggleEnabled={handleToggleProviderEnabled}
|
||||
/>
|
||||
|
||||
<SSOProviderDialog
|
||||
open={ssoDialogOpen}
|
||||
onOpenChange={setSsoDialogOpen}
|
||||
provider={ssoDialogProvider}
|
||||
onSaved={handleProviderSaved}
|
||||
/>
|
||||
|
||||
<AdminUsersSection
|
||||
open={openSections.has("users")}
|
||||
onToggle={() => toggle("users")}
|
||||
|
||||
@@ -5,19 +5,8 @@ import { Input } from "@/components/input";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { SettingRow } from "@/components/section-card";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import {
|
||||
Database,
|
||||
Lock,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Settings,
|
||||
Shield,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { Database, Lock, RefreshCw, Server, Settings } from "lucide-react";
|
||||
import { AccordionSection, AdminToggle } from "./AdminSettingsShared";
|
||||
import type { SSOProvider, SSOProviderType } from "@/types/index";
|
||||
import type { HostDefaults } from "@/api/settings-api";
|
||||
import type { AcmeSettings, AcmeChallengeType } from "@/api/acme-ssl-api";
|
||||
|
||||
@@ -355,268 +344,6 @@ export function AdminGeneralSettingsSection({
|
||||
);
|
||||
}
|
||||
|
||||
type OidcSettingsSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
oidcClientId: string;
|
||||
setOidcClientId: Dispatch<SetStateAction<string>>;
|
||||
oidcClientSecret: string;
|
||||
setOidcClientSecret: Dispatch<SetStateAction<string>>;
|
||||
oidcAuthUrl: string;
|
||||
setOidcAuthUrl: Dispatch<SetStateAction<string>>;
|
||||
oidcIssuerUrl: string;
|
||||
setOidcIssuerUrl: Dispatch<SetStateAction<string>>;
|
||||
oidcTokenUrl: string;
|
||||
setOidcTokenUrl: Dispatch<SetStateAction<string>>;
|
||||
oidcUserIdentifier: string;
|
||||
setOidcUserIdentifier: Dispatch<SetStateAction<string>>;
|
||||
oidcDisplayName: string;
|
||||
setOidcDisplayName: Dispatch<SetStateAction<string>>;
|
||||
oidcScopes: string;
|
||||
setOidcScopes: Dispatch<SetStateAction<string>>;
|
||||
oidcUserinfoUrl: string;
|
||||
setOidcUserinfoUrl: Dispatch<SetStateAction<string>>;
|
||||
oidcAllowedUsers: string;
|
||||
setOidcAllowedUsers: Dispatch<SetStateAction<string>>;
|
||||
oidcAdminGroup: string;
|
||||
setOidcAdminGroup: Dispatch<SetStateAction<string>>;
|
||||
oidcGroupClaim: string;
|
||||
setOidcGroupClaim: Dispatch<SetStateAction<string>>;
|
||||
oidcSaving: boolean;
|
||||
handleRemoveOidc: () => void;
|
||||
handleSaveOidc: () => void;
|
||||
};
|
||||
|
||||
export function AdminOidcSettingsSection({
|
||||
open,
|
||||
onToggle,
|
||||
oidcClientId,
|
||||
setOidcClientId,
|
||||
oidcClientSecret,
|
||||
setOidcClientSecret,
|
||||
oidcAuthUrl,
|
||||
setOidcAuthUrl,
|
||||
oidcIssuerUrl,
|
||||
setOidcIssuerUrl,
|
||||
oidcTokenUrl,
|
||||
setOidcTokenUrl,
|
||||
oidcUserIdentifier,
|
||||
setOidcUserIdentifier,
|
||||
oidcDisplayName,
|
||||
setOidcDisplayName,
|
||||
oidcScopes,
|
||||
setOidcScopes,
|
||||
oidcUserinfoUrl,
|
||||
setOidcUserinfoUrl,
|
||||
oidcAllowedUsers,
|
||||
setOidcAllowedUsers,
|
||||
oidcAdminGroup,
|
||||
setOidcAdminGroup,
|
||||
oidcGroupClaim,
|
||||
setOidcGroupClaim,
|
||||
oidcSaving,
|
||||
handleRemoveOidc,
|
||||
handleSaveOidc,
|
||||
}: OidcSettingsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
label={t("admin.sectionOidc")}
|
||||
icon={<Shield className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.oidcDescription").split("*")[0]}
|
||||
<span className="text-accent-brand">*</span>
|
||||
{t("admin.oidcDescription").split("*")[1]}{" "}
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/oidc"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("admin.oidcDocsLink")}
|
||||
</a>
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcClientId")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={oidcClientId}
|
||||
onChange={(e) => setOidcClientId(e.target.value)}
|
||||
placeholder="your-client-id"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcClientSecret")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={oidcClientSecret}
|
||||
onChange={(e) => setOidcClientSecret(e.target.value)}
|
||||
placeholder="your-client-secret"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcAuthUrl")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={oidcAuthUrl}
|
||||
onChange={(e) => setOidcAuthUrl(e.target.value)}
|
||||
placeholder="https://provider/oauth2/auth"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcIssuerUrl")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={oidcIssuerUrl}
|
||||
onChange={(e) => setOidcIssuerUrl(e.target.value)}
|
||||
placeholder="https://provider"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcTokenUrl")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={oidcTokenUrl}
|
||||
onChange={(e) => setOidcTokenUrl(e.target.value)}
|
||||
placeholder="https://provider/oauth2/token"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcUserIdentifier")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={oidcUserIdentifier}
|
||||
onChange={(e) => setOidcUserIdentifier(e.target.value)}
|
||||
placeholder="sub"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcDisplayName")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={oidcDisplayName}
|
||||
onChange={(e) => setOidcDisplayName(e.target.value)}
|
||||
placeholder="name"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcScopes")} <span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={oidcScopes}
|
||||
onChange={(e) => setOidcScopes(e.target.value)}
|
||||
placeholder="openid email profile"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcUserinfoUrl")}
|
||||
</label>
|
||||
<Input
|
||||
value={oidcUserinfoUrl}
|
||||
onChange={(e) => setOidcUserinfoUrl(e.target.value)}
|
||||
placeholder="https://provider/oauth2/userinfo"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcAllowedUsers")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.oidcAllowedUsersDesc")}
|
||||
</span>
|
||||
<textarea
|
||||
value={oidcAllowedUsers}
|
||||
onChange={(e) => setOidcAllowedUsers(e.target.value)}
|
||||
placeholder={"user@example.com\nanother@example.com"}
|
||||
rows={3}
|
||||
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcAdminGroup")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.oidcAdminGroupDesc")}
|
||||
</span>
|
||||
<input
|
||||
value={oidcAdminGroup}
|
||||
onChange={(e) => setOidcAdminGroup(e.target.value)}
|
||||
placeholder="admin"
|
||||
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.oidcGroupClaim")}
|
||||
</label>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.oidcGroupClaimDesc")}
|
||||
</span>
|
||||
<input
|
||||
value={oidcGroupClaim}
|
||||
onChange={(e) => setOidcGroupClaim(e.target.value)}
|
||||
placeholder="groups"
|
||||
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={handleRemoveOidc}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
{t("admin.removeOidc")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleSaveOidc}
|
||||
disabled={oidcSaving}
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
{oidcSaving ? t("admin.saving") : t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
type DatabaseSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
@@ -708,115 +435,6 @@ export function AdminDatabaseSection({
|
||||
);
|
||||
}
|
||||
|
||||
const SSO_TYPE_LABELS: Record<SSOProviderType, string> = {
|
||||
oidc: "OIDC",
|
||||
ldap: "LDAP",
|
||||
github: "GitHub",
|
||||
google: "Google",
|
||||
};
|
||||
|
||||
type AdminSSOSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
providers: SSOProvider[];
|
||||
onAddProvider: () => void;
|
||||
onEditProvider: (provider: SSOProvider) => void;
|
||||
onDeleteProvider: (id: number) => void;
|
||||
onToggleEnabled: (id: number, enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export function AdminSSOSection({
|
||||
open,
|
||||
onToggle,
|
||||
providers,
|
||||
onAddProvider,
|
||||
onEditProvider,
|
||||
onDeleteProvider,
|
||||
onToggleEnabled,
|
||||
}: AdminSSOSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<AccordionSection
|
||||
label={t("admin.sectionSso")}
|
||||
icon={<Shield className="size-3.5" />}
|
||||
open={open}
|
||||
onToggle={onToggle}
|
||||
>
|
||||
<div className="flex flex-col gap-3 pt-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/sso-providers"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("admin.ssoDocsLink")}
|
||||
</a>
|
||||
</span>
|
||||
{providers.length === 0 ? (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.ssoNoProviders")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{providers.map((provider) => (
|
||||
<div
|
||||
key={provider.id}
|
||||
className="flex items-center gap-2 p-2 border border-border bg-background"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium truncate">
|
||||
{provider.name}
|
||||
</span>
|
||||
<span className="text-[9px] px-1 py-0.5 bg-muted text-muted-foreground font-mono uppercase">
|
||||
{SSO_TYPE_LABELS[provider.type] ?? provider.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<AdminToggle
|
||||
on={provider.enabled}
|
||||
onToggle={() =>
|
||||
onToggleEnabled(provider.id, !provider.enabled)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => onEditProvider(provider)}
|
||||
title={t("admin.ssoEditProvider")}
|
||||
>
|
||||
<Pencil className="size-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => onDeleteProvider(provider.id)}
|
||||
title={t("admin.ssoDeleteProvider")}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={onAddProvider}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
{t("admin.ssoAddProvider")}
|
||||
</Button>
|
||||
</div>
|
||||
</AccordionSection>
|
||||
);
|
||||
}
|
||||
|
||||
type AdminHostDefaultsSectionProps = {
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
|
||||
@@ -1,726 +0,0 @@
|
||||
import { useState, useEffect, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/dialog";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type LDAPProviderConfig,
|
||||
type OIDCProviderConfig,
|
||||
type SSOProvider,
|
||||
type SSOProviderType,
|
||||
} from "@/types/index";
|
||||
import { createSSOProvider, updateSSOProvider } from "@/api/sso-provider-api";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
type ApiErrorLike = {
|
||||
response?: { data?: { error?: string } };
|
||||
message?: string;
|
||||
};
|
||||
|
||||
function apiErrorMsg(error: unknown, fallback: string) {
|
||||
const err = error as ApiErrorLike;
|
||||
return err.response?.data?.error || err.message || fallback;
|
||||
}
|
||||
|
||||
const PROVIDER_TYPE_OPTIONS: { value: SSOProviderType; label: string }[] = [
|
||||
{ value: "oidc", label: "OIDC" },
|
||||
{ value: "github", label: "GitHub" },
|
||||
{ value: "google", label: "Google" },
|
||||
{ value: "ldap", label: "LDAP" },
|
||||
];
|
||||
|
||||
const githubDefaults = {
|
||||
authorization_url: "https://github.com/login/oauth/authorize",
|
||||
token_url: "https://github.com/login/oauth/access_token",
|
||||
issuer_url: "https://token.actions.githubusercontent.com",
|
||||
userinfo_url: "https://api.github.com/user",
|
||||
scopes: "read:user user:email",
|
||||
identifier_path: "id",
|
||||
name_path: "name",
|
||||
};
|
||||
|
||||
const googleDefaults = {
|
||||
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
token_url: "https://oauth2.googleapis.com/token",
|
||||
issuer_url: "https://accounts.google.com",
|
||||
userinfo_url: "https://openidconnect.googleapis.com/v1/userinfo",
|
||||
scopes: "openid email profile",
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
};
|
||||
|
||||
type SSOProviderDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
provider: SSOProvider | null;
|
||||
onSaved: (provider: SSOProvider) => void;
|
||||
};
|
||||
|
||||
function emptyOidc(): OIDCProviderConfig {
|
||||
return {
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
issuer_url: "",
|
||||
authorization_url: "",
|
||||
token_url: "",
|
||||
userinfo_url: "",
|
||||
identifier_path: "sub",
|
||||
name_path: "name",
|
||||
scopes: "openid email profile",
|
||||
allowed_users: "",
|
||||
admin_group: "",
|
||||
group_claim: "",
|
||||
ca_cert: "",
|
||||
};
|
||||
}
|
||||
|
||||
function emptyLdap(): Required<LDAPProviderConfig> {
|
||||
return {
|
||||
host: "",
|
||||
port: 389,
|
||||
useTLS: false,
|
||||
bindDN: "",
|
||||
bindPassword: "",
|
||||
userSearchBase: "",
|
||||
userSearchFilter: "(uid={{username}})",
|
||||
usernameAttribute: "uid",
|
||||
displayNameAttribute: "cn",
|
||||
groupSearchBase: "",
|
||||
adminGroup: "",
|
||||
allowedUsers: "",
|
||||
};
|
||||
}
|
||||
|
||||
type OIDCFields = {
|
||||
client_id: string;
|
||||
client_secret: string;
|
||||
issuer_url: string;
|
||||
authorization_url: string;
|
||||
token_url: string;
|
||||
userinfo_url: string;
|
||||
identifier_path: string;
|
||||
name_path: string;
|
||||
scopes: string;
|
||||
allowed_users: string;
|
||||
admin_group: string;
|
||||
group_claim: string;
|
||||
ca_cert: string;
|
||||
};
|
||||
|
||||
type LDAPFields = {
|
||||
host: string;
|
||||
port: string;
|
||||
useTLS: boolean;
|
||||
bindDN: string;
|
||||
bindPassword: string;
|
||||
userSearchBase: string;
|
||||
userSearchFilter: string;
|
||||
usernameAttribute: string;
|
||||
displayNameAttribute: string;
|
||||
groupSearchBase: string;
|
||||
adminGroup: string;
|
||||
allowedUsers: string;
|
||||
};
|
||||
|
||||
export function SSOProviderDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
provider,
|
||||
onSaved,
|
||||
}: SSOProviderDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = provider != null;
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState<SSOProviderType>("oidc");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [oidc, setOidc] = useState<OIDCFields>(
|
||||
() => ({ ...emptyOidc() }) as OIDCFields,
|
||||
);
|
||||
const [ldap, setLdap] = useState<LDAPFields>(() => {
|
||||
const d = emptyLdap();
|
||||
return { ...d, port: String(d.port) };
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (provider) {
|
||||
setName(provider.name);
|
||||
setType(provider.type);
|
||||
setEnabled(provider.enabled);
|
||||
let config: Record<string, unknown> = {};
|
||||
if (typeof provider.config === "string") {
|
||||
try {
|
||||
config = JSON.parse(provider.config);
|
||||
} catch {
|
||||
/* */
|
||||
}
|
||||
} else if (provider.config && typeof provider.config === "object") {
|
||||
config = provider.config as Record<string, unknown>;
|
||||
}
|
||||
if (provider.type === "ldap") {
|
||||
const d = emptyLdap();
|
||||
setLdap({
|
||||
host: (config.host as string) ?? d.host,
|
||||
port: String((config.port as number) ?? d.port),
|
||||
useTLS: (config.useTLS as boolean) ?? d.useTLS,
|
||||
bindDN: (config.bindDN as string) ?? d.bindDN,
|
||||
bindPassword: (config.bindPassword as string) ?? "",
|
||||
userSearchBase: (config.userSearchBase as string) ?? d.userSearchBase,
|
||||
userSearchFilter:
|
||||
(config.userSearchFilter as string) ?? d.userSearchFilter,
|
||||
usernameAttribute:
|
||||
(config.usernameAttribute as string) ?? d.usernameAttribute,
|
||||
displayNameAttribute:
|
||||
(config.displayNameAttribute as string) ?? d.displayNameAttribute,
|
||||
groupSearchBase: (config.groupSearchBase as string) ?? "",
|
||||
adminGroup: (config.adminGroup as string) ?? "",
|
||||
allowedUsers: (config.allowedUsers as string) ?? "",
|
||||
});
|
||||
} else {
|
||||
const d = emptyOidc();
|
||||
setOidc({
|
||||
client_id: (config.client_id as string) ?? d.client_id,
|
||||
client_secret: (config.client_secret as string) ?? "",
|
||||
issuer_url: (config.issuer_url as string) ?? d.issuer_url,
|
||||
authorization_url:
|
||||
(config.authorization_url as string) ?? d.authorization_url,
|
||||
token_url: (config.token_url as string) ?? d.token_url,
|
||||
userinfo_url: (config.userinfo_url as string) ?? d.userinfo_url,
|
||||
identifier_path:
|
||||
(config.identifier_path as string) ?? d.identifier_path,
|
||||
name_path: (config.name_path as string) ?? d.name_path,
|
||||
scopes: (config.scopes as string) ?? d.scopes,
|
||||
allowed_users: (config.allowed_users as string) ?? d.allowed_users,
|
||||
admin_group: (config.admin_group as string) ?? d.admin_group,
|
||||
group_claim: (config.group_claim as string) ?? "",
|
||||
ca_cert: (config.ca_cert as string) ?? "",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setName("");
|
||||
setType("oidc");
|
||||
setEnabled(true);
|
||||
setOidc({ ...emptyOidc() } as OIDCFields);
|
||||
const d = emptyLdap();
|
||||
setLdap({ ...d, port: String(d.port) });
|
||||
}
|
||||
}, [open, provider]);
|
||||
|
||||
function setOidcField<K extends keyof OIDCFields>(
|
||||
field: K,
|
||||
value: OIDCFields[K],
|
||||
) {
|
||||
setOidc((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
function setLdapField<K extends keyof LDAPFields>(
|
||||
field: K,
|
||||
value: LDAPFields[K],
|
||||
) {
|
||||
setLdap((prev) => ({ ...prev, [field]: value }));
|
||||
}
|
||||
|
||||
function buildConfig(): Record<string, unknown> {
|
||||
if (type === "ldap") {
|
||||
return {
|
||||
host: ldap.host,
|
||||
port: parseInt(ldap.port, 10) || 389,
|
||||
useTLS: ldap.useTLS,
|
||||
bindDN: ldap.bindDN,
|
||||
bindPassword: ldap.bindPassword,
|
||||
userSearchBase: ldap.userSearchBase,
|
||||
userSearchFilter: ldap.userSearchFilter,
|
||||
usernameAttribute: ldap.usernameAttribute || "uid",
|
||||
displayNameAttribute: ldap.displayNameAttribute || "cn",
|
||||
groupSearchBase: ldap.groupSearchBase || undefined,
|
||||
adminGroup: ldap.adminGroup || undefined,
|
||||
allowedUsers: ldap.allowedUsers || undefined,
|
||||
};
|
||||
}
|
||||
const providerDefaults =
|
||||
type === "google"
|
||||
? googleDefaults
|
||||
: type === "github"
|
||||
? githubDefaults
|
||||
: null;
|
||||
return {
|
||||
client_id: oidc.client_id,
|
||||
client_secret: oidc.client_secret,
|
||||
issuer_url: oidc.issuer_url || providerDefaults?.issuer_url || "",
|
||||
authorization_url:
|
||||
oidc.authorization_url || providerDefaults?.authorization_url || "",
|
||||
token_url: oidc.token_url || providerDefaults?.token_url || "",
|
||||
userinfo_url:
|
||||
oidc.userinfo_url || providerDefaults?.userinfo_url || undefined,
|
||||
identifier_path:
|
||||
oidc.identifier_path || providerDefaults?.identifier_path || "sub",
|
||||
name_path: oidc.name_path || providerDefaults?.name_path || "name",
|
||||
scopes: oidc.scopes || providerDefaults?.scopes || "openid email profile",
|
||||
allowed_users: oidc.allowed_users || undefined,
|
||||
admin_group: oidc.admin_group || undefined,
|
||||
group_claim: oidc.group_claim || undefined,
|
||||
ca_cert: oidc.ca_cert || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.trim()) {
|
||||
toast.error(
|
||||
t("admin.ssoProviderName") + " " + t("common.required").toLowerCase(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = {
|
||||
name: name.trim(),
|
||||
type,
|
||||
enabled,
|
||||
displayOrder: 0,
|
||||
config: buildConfig(),
|
||||
};
|
||||
let saved: SSOProvider;
|
||||
if (isEdit && provider) {
|
||||
saved = await updateSSOProvider(provider.id, data);
|
||||
} else {
|
||||
saved = await createSSOProvider(data);
|
||||
}
|
||||
toast.success(t("common.saved"));
|
||||
onSaved(saved);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(apiErrorMsg(err, t("common.saveFailed")));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isGithubOrGoogle = type === "github" || type === "google";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEdit ? t("admin.ssoEditProvider") : t("admin.ssoAddProvider")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.ssoProviderName")}{" "}
|
||||
<span className="text-accent-brand">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="My SSO Provider"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isEdit && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.ssoProviderType")}
|
||||
</label>
|
||||
<Select2
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as SSOProviderType)}
|
||||
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{PROVIDER_TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{t("admin.ssoEnabled")}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEnabled((v) => !v)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${enabled ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${enabled ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-3">
|
||||
{type === "ldap" ? (
|
||||
<LDAPConfigFields ldap={ldap} setLdapField={setLdapField} t={t} />
|
||||
) : (
|
||||
<OIDCConfigFields
|
||||
oidc={oidc}
|
||||
setOidcField={setOidcField}
|
||||
type={type}
|
||||
simplified={isGithubOrGoogle}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? t("admin.saving") : t("admin.ssoSaveProvider")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">
|
||||
{label}
|
||||
{required && <span className="text-accent-brand ml-1">*</span>}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OIDCConfigFields({
|
||||
oidc,
|
||||
setOidcField,
|
||||
type,
|
||||
simplified,
|
||||
t,
|
||||
}: {
|
||||
oidc: OIDCFields;
|
||||
setOidcField: <K extends keyof OIDCFields>(k: K, v: OIDCFields[K]) => void;
|
||||
type: SSOProviderType;
|
||||
simplified: boolean;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const docsHref = simplified
|
||||
? "https://docs.termix.site/features/authentication/github-google"
|
||||
: "https://docs.termix.site/features/authentication/oidc";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
<a
|
||||
href={docsHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("admin.ssoProviderDocsLink")}
|
||||
</a>
|
||||
</span>
|
||||
<Field label={t("admin.oidcClientId")} required>
|
||||
<Input
|
||||
value={oidc.client_id}
|
||||
onChange={(e) => setOidcField("client_id", e.target.value)}
|
||||
placeholder="your-client-id"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.oidcClientSecret")} required>
|
||||
<Input
|
||||
type="password"
|
||||
value={oidc.client_secret}
|
||||
onChange={(e) => setOidcField("client_secret", e.target.value)}
|
||||
placeholder="your-client-secret"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
{simplified ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{type === "github"
|
||||
? `Authorization URL: ${githubDefaults.authorization_url}`
|
||||
: `Authorization URL: ${googleDefaults.authorization_url}`}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Field label={t("admin.oidcIssuerUrl")} required>
|
||||
<Input
|
||||
value={oidc.issuer_url}
|
||||
onChange={(e) => setOidcField("issuer_url", e.target.value)}
|
||||
placeholder="https://provider"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.oidcAuthUrl")} required>
|
||||
<Input
|
||||
value={oidc.authorization_url}
|
||||
onChange={(e) =>
|
||||
setOidcField("authorization_url", e.target.value)
|
||||
}
|
||||
placeholder="https://provider/oauth2/auth"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.oidcTokenUrl")} required>
|
||||
<Input
|
||||
value={oidc.token_url}
|
||||
onChange={(e) => setOidcField("token_url", e.target.value)}
|
||||
placeholder="https://provider/oauth2/token"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.oidcUserIdentifier")} required>
|
||||
<Input
|
||||
value={oidc.identifier_path}
|
||||
onChange={(e) => setOidcField("identifier_path", e.target.value)}
|
||||
placeholder="sub"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.oidcDisplayName")} required>
|
||||
<Input
|
||||
value={oidc.name_path}
|
||||
onChange={(e) => setOidcField("name_path", e.target.value)}
|
||||
placeholder="name"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.oidcScopes")} required>
|
||||
<Input
|
||||
value={oidc.scopes}
|
||||
onChange={(e) => setOidcField("scopes", e.target.value)}
|
||||
placeholder="openid email profile"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.oidcUserinfoUrl")}>
|
||||
<Input
|
||||
value={oidc.userinfo_url}
|
||||
onChange={(e) => setOidcField("userinfo_url", e.target.value)}
|
||||
placeholder="https://provider/oauth2/userinfo"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.oidcGroupClaim")}>
|
||||
<Input
|
||||
value={oidc.group_claim}
|
||||
onChange={(e) => setOidcField("group_claim", e.target.value)}
|
||||
placeholder="groups"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field label={t("admin.oidcAllowedUsers")}>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.oidcAllowedUsersDesc")}
|
||||
</span>
|
||||
<textarea
|
||||
value={oidc.allowed_users}
|
||||
onChange={(e) => setOidcField("allowed_users", e.target.value)}
|
||||
placeholder={"user@example.com\nanother@example.com"}
|
||||
rows={3}
|
||||
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.oidcAdminGroup")}>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.oidcAdminGroupDesc")}
|
||||
</span>
|
||||
<Input
|
||||
value={oidc.admin_group}
|
||||
onChange={(e) => setOidcField("admin_group", e.target.value)}
|
||||
placeholder="admin"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.oidcCaCert")}>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.oidcCaCertDesc")}
|
||||
</span>
|
||||
<textarea
|
||||
value={oidc.ca_cert}
|
||||
onChange={(e) => setOidcField("ca_cert", e.target.value)}
|
||||
placeholder={
|
||||
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
|
||||
}
|
||||
rows={4}
|
||||
className="w-full px-2 py-1.5 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LDAPConfigFields({
|
||||
ldap,
|
||||
setLdapField,
|
||||
t,
|
||||
}: {
|
||||
ldap: LDAPFields;
|
||||
setLdapField: <K extends keyof LDAPFields>(k: K, v: LDAPFields[K]) => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
<a
|
||||
href="https://docs.termix.site/features/authentication/ldap"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-accent-brand hover:underline"
|
||||
>
|
||||
{t("admin.ssoProviderDocsLink")}
|
||||
</a>
|
||||
</span>
|
||||
<Field label={t("admin.ldapHost")} required>
|
||||
<Input
|
||||
value={ldap.host}
|
||||
onChange={(e) => setLdapField("host", e.target.value)}
|
||||
placeholder="ldap.example.com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<Field label={t("admin.ldapPort")} required>
|
||||
<Input
|
||||
value={ldap.port}
|
||||
onChange={(e) => setLdapField("port", e.target.value)}
|
||||
placeholder="389"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="flex items-end pb-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLdapField("useTLS", !ldap.useTLS)}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border-2 transition-colors ${ldap.useTLS ? "bg-accent-brand border-accent-brand" : "bg-muted border-border"}`}
|
||||
>
|
||||
<span
|
||||
className={`pointer-events-none inline-block h-3 w-3 bg-background shadow-sm transition-transform ${ldap.useTLS ? "translate-x-4" : "translate-x-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.ldapUseTls")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Field label={t("admin.ldapBindDn")} required>
|
||||
<Input
|
||||
value={ldap.bindDN}
|
||||
onChange={(e) => setLdapField("bindDN", e.target.value)}
|
||||
placeholder="cn=admin,dc=example,dc=com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.ldapBindPassword")} required>
|
||||
<Input
|
||||
type="password"
|
||||
value={ldap.bindPassword}
|
||||
onChange={(e) => setLdapField("bindPassword", e.target.value)}
|
||||
placeholder="bind password"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.ldapUserSearchBase")} required>
|
||||
<Input
|
||||
value={ldap.userSearchBase}
|
||||
onChange={(e) => setLdapField("userSearchBase", e.target.value)}
|
||||
placeholder="ou=users,dc=example,dc=com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.ldapUserSearchFilter")} required>
|
||||
<Input
|
||||
value={ldap.userSearchFilter}
|
||||
onChange={(e) => setLdapField("userSearchFilter", e.target.value)}
|
||||
placeholder="(uid={{username}})"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.ldapUsernameAttr")} required>
|
||||
<Input
|
||||
value={ldap.usernameAttribute}
|
||||
onChange={(e) => setLdapField("usernameAttribute", e.target.value)}
|
||||
placeholder="uid"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.ldapDisplayNameAttr")} required>
|
||||
<Input
|
||||
value={ldap.displayNameAttribute}
|
||||
onChange={(e) => setLdapField("displayNameAttribute", e.target.value)}
|
||||
placeholder="cn"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<div className="border-t border-border pt-3 flex flex-col gap-3">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("admin.ldapGroupSearchBase")}
|
||||
</span>
|
||||
<Field label={t("admin.ldapGroupSearchBase")}>
|
||||
<Input
|
||||
value={ldap.groupSearchBase}
|
||||
onChange={(e) => setLdapField("groupSearchBase", e.target.value)}
|
||||
placeholder="ou=groups,dc=example,dc=com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.ldapAdminGroup")}>
|
||||
<Input
|
||||
value={ldap.adminGroup}
|
||||
onChange={(e) => setLdapField("adminGroup", e.target.value)}
|
||||
placeholder="cn=admins,ou=groups,dc=example,dc=com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("admin.ldapAllowedUsers")}>
|
||||
<Input
|
||||
value={ldap.allowedUsers}
|
||||
onChange={(e) => setLdapField("allowedUsers", e.target.value)}
|
||||
placeholder="user1,user2,@domain.com"
|
||||
className="text-xs"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,6 @@ const mainAxios = vi.hoisted(() => ({
|
||||
initiatePasswordReset: vi.fn(),
|
||||
verifyPasswordResetCode: vi.fn(),
|
||||
completePasswordReset: vi.fn(),
|
||||
getOIDCAuthorizeUrl: vi.fn(),
|
||||
isElectron: vi.fn(),
|
||||
getCurrentToken: vi.fn(),
|
||||
getOidcSilentLoginDefault: vi.fn(),
|
||||
@@ -29,13 +28,7 @@ const mainAxios = vi.hoisted(() => ({
|
||||
requestTrustedProxyLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
const ssoProviderApi = vi.hoisted(() => ({
|
||||
getSSOProviders: vi.fn(),
|
||||
ldapLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/main-axios", () => mainAxios);
|
||||
vi.mock("@/api/sso-provider-api", () => ssoProviderApi);
|
||||
vi.mock("@/i18n/i18n", () => ({
|
||||
changeAppLanguage: vi.fn(async (code: string) => code),
|
||||
normalizeLanguageCode: vi.fn((code: string | null) => code || "en"),
|
||||
@@ -102,7 +95,6 @@ beforeEach(() => {
|
||||
enabled: false,
|
||||
success: false,
|
||||
});
|
||||
ssoProviderApi.getSSOProviders.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -31,16 +31,10 @@ vi.mock("@/main-axios", () => ({
|
||||
initiatePasswordReset: vi.fn(),
|
||||
verifyPasswordResetCode: vi.fn(),
|
||||
completePasswordReset: vi.fn(),
|
||||
getOIDCAuthorizeUrl: vi.fn(),
|
||||
getCurrentToken: vi.fn().mockReturnValue(null),
|
||||
requestTrustedProxyLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/sso-provider-api", () => ({
|
||||
getSSOProviders: vi.fn().mockResolvedValue([]),
|
||||
ldapLogin: vi.fn(),
|
||||
}));
|
||||
|
||||
import { Auth } from "@/auth/Auth";
|
||||
|
||||
function setEmbeddedServerStatus(failure: unknown) {
|
||||
|
||||
@@ -116,9 +116,9 @@ beforeEach(() => {
|
||||
},
|
||||
{
|
||||
id: "oidc",
|
||||
pluginId: "core",
|
||||
pluginId: "sso",
|
||||
kind: "redirect",
|
||||
labelKey: "auth.loginWithSso",
|
||||
labelKey: "loginWithSso",
|
||||
instances: [{ id: "3", label: "Keycloak" }],
|
||||
},
|
||||
{
|
||||
@@ -149,10 +149,10 @@ async function openExternalTab() {
|
||||
}
|
||||
|
||||
describe("login methods on the login screen", () => {
|
||||
it("lists every enabled method, core and plugin", async () => {
|
||||
it("lists every enabled method", async () => {
|
||||
await openExternalTab();
|
||||
// A redirect method whose plugin UI is not loaded gets a plain button.
|
||||
expect(screen.getByText("auth.loginWithProvider:Keycloak")).toBeTruthy();
|
||||
// A plugin redirect method with no UI of its own gets a plain button.
|
||||
expect(screen.getByText("auth.loginWithProvider:Corp")).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user