Merge branch 'feat/enterprise-on-prem'

This commit is contained in:
SnapOtter
2026-06-14 16:10:15 +08:00
108 changed files with 13231 additions and 2655 deletions
@@ -338,6 +338,8 @@ interface TeamEntry {
id: string;
name: string;
memberCount: number;
storageQuota: number | null;
retentionHours: number | null;
createdAt: string;
}
@@ -686,6 +688,52 @@ function SystemSection() {
</button>
</SettingRow>
<div className="pt-4 border-t border-border">
<h4 className="text-sm font-semibold text-foreground mb-3">
{t.settings.dataRetention.title}
</h4>
</div>
<SettingRow
label={t.settings.dataRetention.fileMaxAgeHours}
description={t.settings.dataRetention.fileMaxAgeHoursDesc}
>
<input
type="number"
value={settings.tempFileMaxAgeHours || "72"}
onChange={(e) => updateSetting("tempFileMaxAgeHours", e.target.value)}
aria-label={t.settings.dataRetention.fileMaxAgeHours}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
min={1}
max={8760}
/>
</SettingRow>
<SettingRow
label={t.settings.dataRetention.jobsRetentionDays}
description={t.settings.dataRetention.jobsRetentionDaysDesc}
>
<input
type="number"
value={settings.jobsRetentionDays || "30"}
onChange={(e) => updateSetting("jobsRetentionDays", e.target.value)}
aria-label={t.settings.dataRetention.jobsRetentionDays}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
min={0}
/>
</SettingRow>
<SettingRow
label={t.settings.dataRetention.auditRetentionDays}
description={t.settings.dataRetention.auditRetentionDaysDesc}
>
<input
type="number"
value={settings.auditRetentionDays || "0"}
onChange={(e) => updateSetting("auditRetentionDays", e.target.value)}
aria-label={t.settings.dataRetention.auditRetentionDays}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
min={0}
/>
</SettingRow>
<div className="flex items-center gap-3 pt-2">
<button
type="button"
@@ -757,6 +805,7 @@ function SystemSection() {
function SecuritySection() {
const { t } = useTranslation();
const { hasPermission } = useAuth();
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
@@ -914,6 +963,278 @@ function SecuritySection() {
<div className="border-t border-border pt-4">
<p className="text-sm text-muted-foreground">{t.settings.security.loginAttemptLimitNote}</p>
</div>
{hasPermission("settings:write") && <AdminSecuritySettings />}
</div>
);
}
function AdminSecuritySettings() {
const { t } = useTranslation();
const [settings, setSettings] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState<string | null>(null);
useEffect(() => {
apiGet<{ settings: Record<string, string> }>("/v1/settings")
.then((data) => setSettings(data.settings))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const updateSetting = useCallback((key: string, value: string) => {
setSettings((prev) => ({ ...prev, [key]: value }));
}, []);
const handleSave = useCallback(async () => {
setSaving(true);
setSaveMsg(null);
try {
await apiPut("/v1/settings", settings);
setSaveMsg(t.settings.security.securitySettingsSaved);
} catch {
setSaveMsg(t.settings.security.securitySettingsFailed);
} finally {
setSaving(false);
setTimeout(() => setSaveMsg(null), 3000);
}
}, [settings, t]);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="border-t border-border pt-6 space-y-6">
<div>
<h4 className="text-sm font-semibold text-foreground">
{t.settings.security.adminHeading}
</h4>
<p className="text-xs text-muted-foreground mt-1">{t.settings.security.adminDescription}</p>
</div>
<SettingRow
label={t.settings.security.sessionIdleTimeout}
description={t.settings.security.sessionIdleTimeoutDesc}
>
<input
type="number"
value={settings.sessionIdleTimeoutMinutes || "0"}
onChange={(e) => updateSetting("sessionIdleTimeoutMinutes", e.target.value)}
aria-label={t.settings.security.sessionIdleTimeout}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
min={0}
/>
</SettingRow>
<SettingRow
label={t.settings.security.maxSessionsPerUser}
description={t.settings.security.maxSessionsPerUserDesc}
>
<input
type="number"
value={settings.maxSessionsPerUser || "0"}
onChange={(e) => updateSetting("maxSessionsPerUser", e.target.value)}
aria-label={t.settings.security.maxSessionsPerUser}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
min={0}
/>
</SettingRow>
<SettingRow
label={t.settings.security.mfaPolicy}
description={t.settings.security.mfaPolicyDesc}
>
<select
value={settings.mfaPolicy || "optional"}
onChange={(e) => updateSetting("mfaPolicy", e.target.value)}
aria-label={t.settings.security.mfaPolicy}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground"
>
<option value="optional">{t.settings.security.mfaPolicyOptional}</option>
<option value="admins_only">{t.settings.security.mfaPolicyAdminsOnly}</option>
<option value="required">{t.settings.security.mfaPolicyRequired}</option>
</select>
</SettingRow>
<SettingRow
label={t.settings.security.ssoEnforcement}
description={t.settings.security.ssoEnforcementDesc}
>
<button
type="button"
role="switch"
aria-checked={settings.ssoEnforcement === "true"}
aria-label={t.settings.security.ssoEnforcement}
onClick={() =>
updateSetting("ssoEnforcement", settings.ssoEnforcement === "true" ? "false" : "true")
}
className={cn(
"w-11 h-6 rounded-full transition-colors relative",
settings.ssoEnforcement === "true" ? "bg-primary" : "bg-muted-foreground/30",
)}
>
<span
className={cn(
"block w-4 h-4 rounded-full bg-white absolute top-1 transition-transform",
settings.ssoEnforcement === "true" ? "translate-x-6" : "translate-x-1",
)}
/>
</button>
</SettingRow>
{settings.ssoEnforcement === "true" && (
<SettingRow
label={t.settings.security.ssoBreakGlassUsername}
description={t.settings.security.ssoBreakGlassUsernameDesc}
>
<input
type="text"
value={settings.ssoBreakGlassUsername || ""}
onChange={(e) => updateSetting("ssoBreakGlassUsername", e.target.value)}
aria-label={t.settings.security.ssoBreakGlassUsername}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-40"
placeholder="admin"
/>
</SettingRow>
)}
<div className="pt-2 border-t border-border">
<h4 className="text-sm font-semibold text-foreground mb-3">
{t.settings.security.passwordPolicyHeading}
</h4>
</div>
<SettingRow
label={t.settings.security.passwordMinLength}
description={t.settings.security.passwordMinLengthDesc}
>
<input
type="number"
value={settings.passwordMinLength || "8"}
onChange={(e) => updateSetting("passwordMinLength", e.target.value)}
aria-label={t.settings.security.passwordMinLength}
className="px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground w-24"
min={4}
max={128}
/>
</SettingRow>
<SettingRow
label={t.settings.security.passwordRequireUppercase}
description={t.settings.security.passwordRequireUppercaseDesc}
>
<button
type="button"
role="switch"
aria-checked={settings.passwordRequireUppercase !== "false"}
aria-label={t.settings.security.passwordRequireUppercase}
onClick={() =>
updateSetting(
"passwordRequireUppercase",
settings.passwordRequireUppercase === "false" ? "true" : "false",
)
}
className={cn(
"w-11 h-6 rounded-full transition-colors relative",
settings.passwordRequireUppercase !== "false" ? "bg-primary" : "bg-muted-foreground/30",
)}
>
<span
className={cn(
"block w-4 h-4 rounded-full bg-white absolute top-1 transition-transform",
settings.passwordRequireUppercase !== "false" ? "translate-x-6" : "translate-x-1",
)}
/>
</button>
</SettingRow>
<SettingRow
label={t.settings.security.passwordRequireNumber}
description={t.settings.security.passwordRequireNumberDesc}
>
<button
type="button"
role="switch"
aria-checked={settings.passwordRequireNumber !== "false"}
aria-label={t.settings.security.passwordRequireNumber}
onClick={() =>
updateSetting(
"passwordRequireNumber",
settings.passwordRequireNumber === "false" ? "true" : "false",
)
}
className={cn(
"w-11 h-6 rounded-full transition-colors relative",
settings.passwordRequireNumber !== "false" ? "bg-primary" : "bg-muted-foreground/30",
)}
>
<span
className={cn(
"block w-4 h-4 rounded-full bg-white absolute top-1 transition-transform",
settings.passwordRequireNumber !== "false" ? "translate-x-6" : "translate-x-1",
)}
/>
</button>
</SettingRow>
<SettingRow
label={t.settings.security.passwordRequireSpecial}
description={t.settings.security.passwordRequireSpecialDesc}
>
<button
type="button"
role="switch"
aria-checked={settings.passwordRequireSpecial === "true"}
aria-label={t.settings.security.passwordRequireSpecial}
onClick={() =>
updateSetting(
"passwordRequireSpecial",
settings.passwordRequireSpecial === "true" ? "false" : "true",
)
}
className={cn(
"w-11 h-6 rounded-full transition-colors relative",
settings.passwordRequireSpecial === "true" ? "bg-primary" : "bg-muted-foreground/30",
)}
>
<span
className={cn(
"block w-4 h-4 rounded-full bg-white absolute top-1 transition-transform",
settings.passwordRequireSpecial === "true" ? "translate-x-6" : "translate-x-1",
)}
/>
</button>
</SettingRow>
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
{t.settings.system.saveButton}
</button>
{saveMsg && (
<span
className={cn(
"text-sm",
saveMsg === t.settings.security.securitySettingsFailed
? "text-destructive"
: "text-green-600 dark:text-green-400",
)}
>
{saveMsg}
</span>
)}
</div>
</div>
);
}
@@ -1870,6 +2191,10 @@ function TeamsSection() {
const [editingTeamId, setEditingTeamId] = useState<string | null>(null);
const [editingTeamName, setEditingTeamName] = useState("");
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
const [expandedTeamId, setExpandedTeamId] = useState<string | null>(null);
const [quotaMb, setQuotaMb] = useState("");
const [retention, setRetention] = useState("");
const [savingQuota, setSavingQuota] = useState(false);
const [actionMsg, setActionMsg] = useState<{ type: "success" | "error"; text: string } | null>(
null,
);
@@ -1960,6 +2285,43 @@ function TeamsSection() {
[loadTeams],
);
const handleExpandTeam = useCallback(
(tm: TeamEntry) => {
if (expandedTeamId === tm.id) {
setExpandedTeamId(null);
return;
}
setExpandedTeamId(tm.id);
setQuotaMb(tm.storageQuota ? String(Math.round(tm.storageQuota / (1024 * 1024))) : "");
setRetention(tm.retentionHours ? String(tm.retentionHours) : "");
},
[expandedTeamId],
);
const handleSaveQuota = useCallback(
async (id: string) => {
setSavingQuota(true);
try {
const body: Record<string, number | null> = {};
const mbVal = quotaMb.trim() ? Number(quotaMb) : 0;
body.storageQuota = mbVal > 0 ? mbVal * 1024 * 1024 : null;
const retVal = retention.trim() ? Number(retention) : 0;
body.retentionHours = retVal > 0 ? retVal : null;
await apiPut(`/v1/teams/${id}`, body);
setActionMsg({ type: "success", text: t.settings.teams.quotaSaved });
setExpandedTeamId(null);
await loadTeams();
} catch (err) {
const msg = err instanceof Error ? err.message : "Failed to save";
setActionMsg({ type: "error", text: msg });
} finally {
setSavingQuota(false);
setTimeout(() => setActionMsg(null), 3000);
}
},
[quotaMb, retention, loadTeams],
);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
@@ -2049,97 +2411,177 @@ function TeamsSection() {
</div>
) : (
teams.map((tm) => (
<div
key={tm.id}
className={cn(
"items-center px-4 py-3 border-b border-border last:border-0 last:rounded-b-lg hover:bg-muted/20 transition-colors",
isMobile ? "flex gap-3" : "grid grid-cols-[1fr_100px_60px] gap-2",
)}
>
<div className="flex-1 min-w-0">
{editingTeamId === tm.id ? (
<Fragment key={tm.id}>
<div
className={cn(
"items-center px-4 py-3 border-b border-border last:border-0 last:rounded-b-lg hover:bg-muted/20 transition-colors",
isMobile ? "flex gap-3" : "grid grid-cols-[1fr_100px_60px] gap-2",
)}
>
<div className="flex-1 min-w-0">
{editingTeamId === tm.id ? (
<div className="flex items-center gap-2">
<input
type="text"
value={editingTeamName}
onChange={(e) => setEditingTeamName(e.target.value)}
className="px-2 py-1 rounded border border-border bg-background text-sm text-foreground w-40"
ref={(el) => el?.focus()}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename(tm.id);
if (e.key === "Escape") setEditingTeamId(null);
}}
/>
<button
type="button"
onClick={() => handleRename(tm.id)}
className="text-xs text-primary hover:underline"
>
{t.common.save}
</button>
<button
type="button"
onClick={() => setEditingTeamId(null)}
className="text-xs text-muted-foreground hover:underline"
>
{t.common.cancel}
</button>
</div>
) : (
<div>
<span className="text-sm font-medium text-foreground truncate block">
{tm.name}
</span>
{isMobile && (
<span className="text-xs text-muted-foreground">
{tm.memberCount} {plural(tm.memberCount, "member", "members")}
</span>
)}
</div>
)}
</div>
{!isMobile && (
<span className="text-sm text-muted-foreground">{tm.memberCount}</span>
)}
<div className="flex items-center gap-1 justify-end relative shrink-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpenMenuId(openMenuId === tm.id ? null : tm.id);
}}
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<MoreVertical className="h-4 w-4" />
</button>
{openMenuId === tm.id && (
<div
role="menu"
className="absolute right-0 top-8 z-50 w-36 rounded-lg border border-border bg-background shadow-lg py-1"
>
<button
type="button"
onClick={() => {
setEditingTeamId(tm.id);
setEditingTeamName(tm.name);
setOpenMenuId(null);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
{t.settings.teams.renameAction}
</button>
<button
type="button"
onClick={() => {
handleExpandTeam(tm);
setOpenMenuId(null);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
<Settings className="h-3.5 w-3.5" />
{t.settings.heading}
</button>
<div className="border-t border-border my-1" />
<button
type="button"
onClick={() => handleDelete(tm.id, tm.name)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
{t.settings.teams.deleteAction}
</button>
</div>
)}
</div>
</div>
{expandedTeamId === tm.id && (
<div className="px-4 py-3 border-b border-border last:border-0 bg-muted/10 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1">
<label
htmlFor={`quota-${tm.id}`}
className="text-xs font-medium text-muted-foreground"
>
{t.settings.teams.teamStorageQuota}
</label>
<input
id={`quota-${tm.id}`}
type="number"
min="0"
value={quotaMb}
onChange={(e) => setQuotaMb(e.target.value)}
placeholder="0"
className="w-full px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground"
/>
<p className="text-[11px] text-muted-foreground">
{t.settings.teams.teamStorageQuotaDesc}
</p>
</div>
<div className="space-y-1">
<label
htmlFor={`retention-${tm.id}`}
className="text-xs font-medium text-muted-foreground"
>
{t.settings.teams.teamRetentionHours}
</label>
<input
id={`retention-${tm.id}`}
type="number"
min="0"
value={retention}
onChange={(e) => setRetention(e.target.value)}
placeholder="0"
className="w-full px-3 py-1.5 rounded-lg border border-border bg-background text-sm text-foreground"
/>
<p className="text-[11px] text-muted-foreground">
{t.settings.teams.teamRetentionHoursDesc}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={editingTeamName}
onChange={(e) => setEditingTeamName(e.target.value)}
className="px-2 py-1 rounded border border-border bg-background text-sm text-foreground w-40"
ref={(el) => el?.focus()}
onKeyDown={(e) => {
if (e.key === "Enter") handleRename(tm.id);
if (e.key === "Escape") setEditingTeamId(null);
}}
/>
<button
type="button"
onClick={() => handleRename(tm.id)}
className="text-xs text-primary hover:underline"
disabled={savingQuota}
onClick={() => handleSaveQuota(tm.id)}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary text-primary-foreground text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{savingQuota && (
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
)}
{t.common.save}
</button>
<button
type="button"
onClick={() => setEditingTeamId(null)}
className="text-xs text-muted-foreground hover:underline"
onClick={() => setExpandedTeamId(null)}
className="px-3 py-1.5 rounded-lg border border-border text-xs text-muted-foreground hover:bg-muted transition-colors"
>
{t.common.cancel}
</button>
</div>
) : (
<div>
<span className="text-sm font-medium text-foreground truncate block">
{tm.name}
</span>
{isMobile && (
<span className="text-xs text-muted-foreground">
{tm.memberCount} {plural(tm.memberCount, "member", "members")}
</span>
)}
</div>
)}
</div>
{!isMobile && <span className="text-sm text-muted-foreground">{tm.memberCount}</span>}
<div className="flex items-center gap-1 justify-end relative shrink-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpenMenuId(openMenuId === tm.id ? null : tm.id);
}}
className="p-1.5 rounded-lg hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
>
<MoreVertical className="h-4 w-4" />
</button>
{openMenuId === tm.id && (
<div
role="menu"
className="absolute right-0 top-8 z-50 w-36 rounded-lg border border-border bg-background shadow-lg py-1"
>
<button
type="button"
onClick={() => {
setEditingTeamId(tm.id);
setEditingTeamName(tm.name);
setOpenMenuId(null);
}}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors"
>
<Pencil className="h-3.5 w-3.5" />
{t.settings.teams.renameAction}
</button>
<div className="border-t border-border my-1" />
<button
type="button"
onClick={() => handleDelete(tm.id, tm.name)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
{t.settings.teams.deleteAction}
</button>
</div>
)}
</div>
</div>
</div>
)}
</Fragment>
))
)}
</div>
@@ -2525,26 +2967,39 @@ function RolesSection() {
const AUDIT_ACTIONS = [
"LOGIN_SUCCESS",
"LOGIN_FAILED",
"USER_CREATED",
"USER_UPDATED",
"USER_DELETED",
"LOGOUT",
"PASSWORD_CHANGED",
"PASSWORD_RESET",
"USER_CREATED",
"USER_DELETED",
"USER_UPDATED",
"FILE_UPLOADED",
"FILE_DELETED",
"API_KEY_CREATED",
"API_KEY_DELETED",
"ROLE_CREATED",
"ROLE_UPDATED",
"ROLE_DELETED",
"SETTINGS_UPDATED",
"OIDC_LOGIN_SUCCESS",
"OIDC_USER_CREATED",
"OIDC_USER_LINKED",
"OIDC_LOGIN_FAILED",
"TOOL_EXECUTED",
"BATCH_EXECUTED",
"PIPELINE_EXECUTED",
] as const;
interface AuditEntry {
id: string;
actorId: string | null;
actorUsername: string;
action: string;
targetType: string | null;
targetId: string | null;
details: Record<string, unknown> | null;
ipAddress: string | null;
requestId: string | null;
createdAt: string;
}
@@ -2646,6 +3101,11 @@ function AuditLogSection() {
</div>
<div className="flex items-center gap-2 mt-1">
<span className="text-sm text-foreground">{entry.actorUsername}</span>
{entry.ipAddress && (
<span className="font-mono text-xs text-muted-foreground tabular-nums">
{entry.ipAddress}
</span>
)}
{entry.targetType && (
<span className="text-xs text-muted-foreground">
{entry.targetType}
@@ -2674,6 +3134,7 @@ function AuditLogSection() {
<th className="text-start px-3 py-2 font-medium text-muted-foreground">
{t.settings.auditLog.tableHeaderUser}
</th>
<th className="text-start px-3 py-2 font-medium text-muted-foreground">IP</th>
<th className="text-start px-3 py-2 font-medium text-muted-foreground">
{t.settings.auditLog.tableHeaderAction}
</th>
@@ -2693,6 +3154,9 @@ function AuditLogSection() {
{formatRelativeTime(entry.createdAt)}
</td>
<td className="px-3 py-2 text-foreground">{entry.actorUsername}</td>
<td className="px-3 py-2 font-mono text-xs text-muted-foreground tabular-nums whitespace-nowrap">
{entry.ipAddress ?? "---"}
</td>
<td className="px-3 py-2">
<span className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">
{entry.action}
@@ -2706,7 +3170,7 @@ function AuditLogSection() {
</tr>
{expandedId === entry.id && entry.details && (
<tr className="border-b border-border last:border-0">
<td colSpan={4} className="px-3 py-2 bg-muted/10">
<td colSpan={5} className="px-3 py-2 bg-muted/10">
<pre className="text-xs text-muted-foreground whitespace-pre-wrap font-mono overflow-x-auto">
{JSON.stringify(entry.details, null, 2)}
</pre>
@@ -12,6 +12,7 @@ interface UsageData {
perUser: Array<{ username: string | null; runs: number; bytesIn: string }>;
durations: Array<{ pool: string; p50Ms: number | null; p95Ms: number | null }>;
storage: { libraryBytes: string; libraryFiles: number };
teamStorage?: Array<{ teamName: string; totalBytes: string; userCount: number }>;
}
export function UsageSection() {
@@ -260,6 +261,43 @@ export function UsageSection() {
</div>
</div>
</div>
{/* Storage by Team */}
{data.teamStorage && data.teamStorage.length > 0 && (
<div className="rounded-lg border border-border p-4 space-y-3">
<h4 className="text-sm font-semibold text-foreground">
{t.settings.usage.storageByTeam}
</h4>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-start py-1.5 text-xs font-medium text-muted-foreground">
{t.settings.usage.teamColumn}
</th>
<th className="text-end py-1.5 text-xs font-medium text-muted-foreground">
{t.settings.usage.storageColumn}
</th>
<th className="text-end py-1.5 text-xs font-medium text-muted-foreground">
{t.settings.usage.usersColumn}
</th>
</tr>
</thead>
<tbody>
{data.teamStorage.map((row) => (
<tr key={row.teamName} className="border-b border-border last:border-0">
<td className="py-1.5 text-foreground">{row.teamName}</td>
<td className="py-1.5 text-end text-muted-foreground font-mono">
{formatFileSize(Number(row.totalBytes))}
</td>
<td className="py-1.5 text-end text-muted-foreground font-mono">
{row.userCount}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
) : null}
</div>
+20
View File
@@ -7,6 +7,7 @@ interface AuthState {
authEnabled: boolean;
isAuthenticated: boolean;
mustChangePassword: boolean;
mfaRequired: boolean;
role: string | null;
permissions: string[];
analyticsEnabled: boolean | null;
@@ -14,6 +15,9 @@ interface AuthState {
analyticsConsentRemindAt: number | null;
oidcEnabled: boolean;
oidcProviderName: string | null;
samlEnabled: boolean;
samlProviderName: string | null;
ssoEnforced: boolean;
loginMethod: string | null;
hasLocalPassword: boolean;
}
@@ -41,6 +45,7 @@ export function useAuth() {
authEnabled: false,
isAuthenticated: false,
mustChangePassword: false,
mfaRequired: false,
role: null,
permissions: [],
analyticsEnabled: null,
@@ -48,6 +53,9 @@ export function useAuth() {
analyticsConsentRemindAt: null,
oidcEnabled: false,
oidcProviderName: null,
samlEnabled: false,
samlProviderName: null,
ssoEnforced: false,
loginMethod: null,
hasLocalPassword: false,
});
@@ -67,6 +75,7 @@ export function useAuth() {
authEnabled: false,
isAuthenticated: true,
mustChangePassword: false,
mfaRequired: false,
role: "admin",
permissions: ANON_ADMIN_PERMISSIONS,
analyticsEnabled: null,
@@ -74,6 +83,9 @@ export function useAuth() {
analyticsConsentRemindAt: null,
oidcEnabled: false,
oidcProviderName: null,
samlEnabled: false,
samlProviderName: null,
ssoEnforced: false,
loginMethod: null,
hasLocalPassword: false,
});
@@ -95,6 +107,7 @@ export function useAuth() {
authEnabled: true,
isAuthenticated: true,
mustChangePassword: mustChange,
mfaRequired: session.user?.mfaRequired === true,
role: session.user?.role ?? null,
permissions: session.user?.permissions ?? [],
analyticsEnabled: session.user?.analyticsEnabled ?? null,
@@ -102,6 +115,9 @@ export function useAuth() {
analyticsConsentRemindAt: session.user?.analyticsConsentRemindAt ?? null,
oidcEnabled: config.oidcEnabled ?? false,
oidcProviderName: config.oidcProviderName ?? null,
samlEnabled: config.samlEnabled ?? false,
samlProviderName: config.samlProviderName ?? null,
ssoEnforced: config.ssoEnforced ?? false,
loginMethod: session.user?.loginMethod ?? null,
hasLocalPassword: session.user?.hasLocalPassword ?? false,
});
@@ -113,6 +129,7 @@ export function useAuth() {
authEnabled: true,
isAuthenticated: false,
mustChangePassword: false,
mfaRequired: false,
role: null,
permissions: [],
analyticsEnabled: null,
@@ -120,6 +137,9 @@ export function useAuth() {
analyticsConsentRemindAt: null,
oidcEnabled: config.oidcEnabled ?? false,
oidcProviderName: config.oidcProviderName ?? null,
samlEnabled: config.samlEnabled ?? false,
samlProviderName: config.samlProviderName ?? null,
ssoEnforced: config.ssoEnforced ?? false,
loginMethod: null,
hasLocalPassword: false,
});
+204 -48
View File
@@ -127,24 +127,32 @@ function LanguageSelector() {
export function LoginPage() {
const { t } = useTranslation();
const { oidcEnabled, oidcProviderName } = useAuth();
const { oidcEnabled, oidcProviderName, samlEnabled, samlProviderName, ssoEnforced } = useAuth();
const [searchParams] = useSearchParams();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [showMfaPrompt, setShowMfaPrompt] = useState(false);
const [mfaToken, setMfaToken] = useState("");
const [mfaCode, setMfaCode] = useState("");
const [mfaLoading, setMfaLoading] = useState(false);
const mfaInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const oidcError = searchParams.get("error");
if (oidcError) {
const authError = searchParams.get("error");
if (authError) {
const errorMessages: Record<string, string> = {
oidc_auth_failed: t.auth.oidcAuthFailed,
oidc_provider_unreachable: t.auth.oidcProviderUnreachable,
oidc_session_expired: t.auth.oidcSessionExpired,
oidc_user_not_authorized: t.auth.oidcUserNotAuthorized,
oidc_user_limit_reached: t.auth.oidcUserLimitReached,
saml_auth_failed: t.auth.samlAuthFailed,
saml_user_not_authorized: t.auth.samlUserNotAuthorized,
saml_user_limit_reached: t.auth.samlUserLimitReached,
};
setError(errorMessages[oidcError] || t.auth.oidcGenericError);
setError(errorMessages[authError] || t.auth.oidcGenericError);
}
}, [searchParams, t]);
@@ -164,6 +172,12 @@ export function LoginPage() {
return;
}
const data = await res.json();
if (data.requiresMfa) {
setMfaToken(data.mfaToken);
setShowMfaPrompt(true);
setTimeout(() => mfaInputRef.current?.focus(), 100);
return;
}
setToken(data.token);
localStorage.setItem("snapotter-username", data.user?.username || username);
if (data.user?.mustChangePassword) {
@@ -178,6 +192,35 @@ export function LoginPage() {
}
};
const handleMfaComplete = async () => {
setMfaLoading(true);
setError("");
try {
const res = await fetch("/api/auth/mfa/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mfaToken, code: mfaCode }),
});
if (!res.ok) {
setError(t.auth.mfaInvalidCode);
setMfaCode("");
return;
}
const data = await res.json();
setToken(data.token);
localStorage.setItem("snapotter-username", data.user?.username || username);
if (data.user?.mustChangePassword) {
window.location.href = "/change-password";
} else {
window.location.href = "/";
}
} catch {
setError(t.auth.connectionError);
} finally {
setMfaLoading(false);
}
};
return (
<div className="flex h-screen bg-background">
<div className="flex-1 flex items-center justify-center p-8">
@@ -188,61 +231,174 @@ export function LoginPage() {
</h1>
<h2 className="text-2xl font-bold mt-4 text-foreground">{t.auth.login}</h2>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium mb-1 text-foreground">
{t.auth.username}
</label>
{ssoEnforced && (oidcEnabled || samlEnabled) && (
<div className="space-y-3">
{oidcEnabled && (
<a
href="/api/auth/oidc/login"
className="w-full py-3 rounded-lg bg-primary/80 text-primary-foreground font-medium hover:bg-primary transition-colors flex items-center justify-center gap-2"
>
{format(t.auth.signInWith, { provider: oidcProviderName || "SSO" })}
</a>
)}
{samlEnabled && (
<a
href="/api/auth/saml/login"
className="w-full py-3 rounded-lg bg-primary/80 text-primary-foreground font-medium hover:bg-primary transition-colors flex items-center justify-center gap-2"
>
{format(t.auth.signInWith, { provider: samlProviderName || "SSO" })}
</a>
)}
<div className="flex items-center gap-3 my-4">
<div className="flex-1 border-t border-border" />
<span className="text-sm text-muted-foreground">{t.auth.or}</span>
<div className="flex-1 border-t border-border" />
</div>
<p className="text-sm text-muted-foreground text-center">
{t.auth.ssoEnforcedLocalRestricted}
</p>
</div>
)}
{showMfaPrompt ? (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
role="img"
aria-hidden="true"
>
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</div>
<div>
<p className="text-sm font-medium text-foreground">{t.auth.mfaRequired}</p>
</div>
</div>
<input
id="username"
ref={mfaInputRef}
type="text"
name="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t.auth.enterUsername}
className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
required
inputMode="numeric"
pattern="[0-9]*"
maxLength={8}
autoComplete="one-time-code"
placeholder="000000"
value={mfaCode}
onChange={(e) => setMfaCode(e.target.value.replace(/[^0-9]/g, ""))}
onKeyDown={(e) => {
if (e.key === "Enter" && mfaCode.length >= 6) handleMfaComplete();
}}
className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground text-center text-2xl font-mono tracking-[0.5em] focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
{error && <p className="text-sm text-destructive">{error}</p>}
<button
type="button"
onClick={handleMfaComplete}
disabled={mfaLoading || mfaCode.length < 6}
className="w-full py-3 rounded-lg bg-primary/80 text-primary-foreground font-medium hover:bg-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{mfaLoading ? t.auth.verifying : t.auth.verify}
</button>
<p className="text-xs text-muted-foreground text-center">{t.auth.mfaRecoveryHint}</p>
<button
type="button"
onClick={() => {
setShowMfaPrompt(false);
setMfaToken("");
setMfaCode("");
setError("");
}}
className="w-full text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{t.common.back}
</button>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-1 text-foreground">
{t.auth.password}
</label>
<input
id="password"
type="password"
name="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t.auth.enterPassword}
className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<button
type="submit"
disabled={loading || !username || !password}
className="w-full py-3 rounded-lg bg-primary/80 text-primary-foreground font-medium hover:bg-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
) : (
<form
onSubmit={handleSubmit}
className={`space-y-4${ssoEnforced ? " opacity-60" : ""}`}
>
{loading ? t.auth.loggingIn : t.auth.loginButton}
</button>
</form>
{oidcEnabled && (
<div>
<label
htmlFor="username"
className="block text-sm font-medium mb-1 text-foreground"
>
{t.auth.username}
</label>
<input
id="username"
type="text"
name="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t.auth.enterUsername}
className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
required
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium mb-1 text-foreground"
>
{t.auth.password}
</label>
<input
id="password"
type="password"
name="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t.auth.enterPassword}
className="w-full px-4 py-3 rounded-lg border border-border bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary/20"
required
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<button
type="submit"
disabled={loading || !username || !password}
className="w-full py-3 rounded-lg bg-primary/80 text-primary-foreground font-medium hover:bg-primary transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? t.auth.loggingIn : t.auth.loginButton}
</button>
</form>
)}
{!ssoEnforced && (oidcEnabled || samlEnabled) && (
<>
<div className="flex items-center gap-3 my-4">
<div className="flex-1 border-t border-border" />
<span className="text-sm text-muted-foreground">{t.auth.or}</span>
<div className="flex-1 border-t border-border" />
</div>
<a
href="/api/auth/oidc/login"
className="w-full py-3 rounded-lg bg-secondary text-secondary-foreground font-medium hover:bg-secondary/80 transition-colors flex items-center justify-center gap-2"
>
{format(t.auth.signInWith, { provider: oidcProviderName || "SSO" })}
</a>
{oidcEnabled && (
<a
href="/api/auth/oidc/login"
className="w-full py-3 rounded-lg bg-secondary text-secondary-foreground font-medium hover:bg-secondary/80 transition-colors flex items-center justify-center gap-2"
>
{format(t.auth.signInWith, { provider: oidcProviderName || "SSO" })}
</a>
)}
{samlEnabled && (
<a
href="/api/auth/saml/login"
className="w-full py-3 rounded-lg bg-secondary text-secondary-foreground font-medium hover:bg-secondary/80 transition-colors flex items-center justify-center gap-2 mt-2"
>
{format(t.auth.signInWith, { provider: samlProviderName || "SSO" })}
</a>
)}
</>
)}
<div className="pt-2">