mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge branch 'feat/enterprise-on-prem'
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user