Server (NestJS): - AcadeniceAuditLogModule: GET /api/acadenice/audit-log (admin/owner, Kysely, paginated + filtered) - AcadeniceApiKeysModule: GET/POST/DELETE /api/acadenice/api-keys (JWT, bcrypt hash, acdk_ prefix) - AcadeniceSecurityModule: GET /api/acadenice/security/oidc-status (admin, no secrets exposed) - Migration 20260510T100000: acadenice_api_key table with token_hash + bcrypt - Permissions catalog: added audit_log:read Client (React 18 + Mantine v7): - Audit log page: paginated table with filters (event, userId, date range) - API keys page: list/create/revoke personal tokens, one-time display modal - Security/OIDC status page: read-only, env-var config reference - Sidebar rewired: Security & SSO, API keys, Audit log -> acadenice/* routes (no EE feature gates) - Prefetch functions for new routes Tests: 36 server (Jest) + client typecheck clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
154 lines
4.5 KiB
TypeScript
154 lines
4.5 KiB
TypeScript
import { Alert, Badge, Code, Divider, Stack, Text, Title } from "@mantine/core";
|
|
import { IconInfoCircle, IconLock, IconShieldCheck } from "@tabler/icons-react";
|
|
import { Helmet } from "react-helmet-async";
|
|
import { useTranslation } from "react-i18next";
|
|
import SettingsTitle from "@/components/settings/settings-title";
|
|
import { getAppName } from "@/lib/config";
|
|
import { useOidcStatusQuery } from "../queries/oidc-status.queries";
|
|
import useUserRole from "@/hooks/use-user-role";
|
|
|
|
export default function AcadeniceSecurityPage() {
|
|
const { t } = useTranslation();
|
|
const { isAdmin } = useUserRole();
|
|
const { data: oidc, isLoading } = useOidcStatusQuery();
|
|
|
|
if (!isAdmin) {
|
|
return (
|
|
<Text c="dimmed">
|
|
{t("You do not have permission to view security settings.")}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Helmet>
|
|
<title>
|
|
{t("Security")} - {getAppName()}
|
|
</title>
|
|
</Helmet>
|
|
|
|
<SettingsTitle title={t("Security")} />
|
|
|
|
<Alert
|
|
icon={<IconInfoCircle size={16} />}
|
|
color="blue"
|
|
variant="light"
|
|
mb="lg"
|
|
>
|
|
{t(
|
|
"Security settings are configured server-side via environment variables. Contact your system administrator to modify them.",
|
|
)}
|
|
</Alert>
|
|
|
|
<Title order={4} mb="sm">
|
|
<IconShieldCheck size={18} style={{ marginRight: 6, verticalAlign: "middle" }} />
|
|
{t("Single Sign-On (OIDC)")}
|
|
</Title>
|
|
|
|
{isLoading ? (
|
|
<Text c="dimmed">{t("Loading...")}</Text>
|
|
) : (
|
|
<Stack gap="sm" mb="lg">
|
|
<Text fz="sm">
|
|
{t("Status")}:{" "}
|
|
<Badge color={oidc?.enabled ? "green" : "gray"} variant="light">
|
|
{oidc?.enabled ? t("Enabled") : t("Disabled")}
|
|
</Badge>
|
|
</Text>
|
|
|
|
{oidc?.enabled && (
|
|
<>
|
|
{oidc.providerName && (
|
|
<Text fz="sm">
|
|
{t("Provider")}: <strong>{oidc.providerName}</strong>
|
|
</Text>
|
|
)}
|
|
|
|
{oidc.issuer && (
|
|
<Text fz="sm">
|
|
{t("Issuer")}: <Code>{oidc.issuer}</Code>
|
|
</Text>
|
|
)}
|
|
|
|
{oidc.scopes && (
|
|
<Text fz="sm">
|
|
{t("Scopes")}: <Code>{oidc.scopes}</Code>
|
|
</Text>
|
|
)}
|
|
|
|
{oidc.redirectUri && (
|
|
<Text fz="sm">
|
|
{t("Redirect URI")}: <Code>{oidc.redirectUri}</Code>
|
|
</Text>
|
|
)}
|
|
|
|
{oidc.loginUrl && (
|
|
<Text fz="sm">
|
|
{t("Login URL")}:{" "}
|
|
<Code>
|
|
{typeof window !== "undefined"
|
|
? window.location.origin + oidc.loginUrl
|
|
: oidc.loginUrl}
|
|
</Code>
|
|
</Text>
|
|
)}
|
|
</>
|
|
)}
|
|
</Stack>
|
|
)}
|
|
|
|
<Divider my="lg" />
|
|
|
|
<Title order={5} mb="xs">
|
|
{t("Configuration")}
|
|
</Title>
|
|
<Text fz="sm" c="dimmed" mb="md">
|
|
{t(
|
|
"OIDC is configured via environment variables on the server. The following variables are supported:",
|
|
)}
|
|
</Text>
|
|
|
|
<Stack gap="xs">
|
|
{[
|
|
{ key: "OIDC_ENABLED", desc: t("Enable OIDC login (true/false)") },
|
|
{ key: "OIDC_ISSUER", desc: t("Provider discovery URL") },
|
|
{ key: "OIDC_CLIENT_ID", desc: t("OAuth2 client ID") },
|
|
{
|
|
key: "OIDC_CLIENT_SECRET",
|
|
desc: t("OAuth2 client secret (server-only, never exposed)"),
|
|
},
|
|
{ key: "OIDC_REDIRECT_URI", desc: t("Callback URL (optional)") },
|
|
{
|
|
key: "OIDC_SCOPES",
|
|
desc: t("OAuth2 scopes (default: openid email profile)"),
|
|
},
|
|
{
|
|
key: "OIDC_PROVIDER_NAME",
|
|
desc: t("Label shown on login button"),
|
|
},
|
|
{
|
|
key: "OIDC_AUTO_PROVISION",
|
|
desc: t("Auto-create user on first login (true/false)"),
|
|
},
|
|
].map(({ key, desc }) => (
|
|
<Text fz="sm" key={key}>
|
|
<Code>{key}</Code> — {desc}
|
|
</Text>
|
|
))}
|
|
</Stack>
|
|
|
|
<Divider my="lg" />
|
|
|
|
<Title order={5} mb="xs">
|
|
<IconLock size={16} style={{ marginRight: 4, verticalAlign: "middle" }} />
|
|
{t("API keys")}
|
|
</Title>
|
|
<Text fz="sm" c="dimmed">
|
|
{t(
|
|
"Personal API keys can be managed from Account > API keys. Rotate them every 90 days. Never commit tokens to source control.",
|
|
)}
|
|
</Text>
|
|
</>
|
|
);
|
|
}
|