mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(web): add MFA login prompt and security settings UI
Handle MFA challenge in login flow (TOTP code input after password verification, recovery code hint, back navigation). Add admin security settings section with session idle timeout, max sessions per user, MFA policy selector, SSO enforcement toggle with break-glass username, and password policy controls. Propagate new i18n keys to all 21 locales.
This commit is contained in:
@@ -805,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("");
|
||||
@@ -962,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>
|
||||
);
|
||||
}
|
||||
@@ -2019,9 +2292,7 @@ function TeamsSection() {
|
||||
return;
|
||||
}
|
||||
setExpandedTeamId(tm.id);
|
||||
setQuotaMb(
|
||||
tm.storageQuota ? String(Math.round(tm.storageQuota / (1024 * 1024))) : "",
|
||||
);
|
||||
setQuotaMb(tm.storageQuota ? String(Math.round(tm.storageQuota / (1024 * 1024))) : "");
|
||||
setRetention(tm.retentionHours ? String(tm.retentionHours) : "");
|
||||
},
|
||||
[expandedTeamId],
|
||||
@@ -2140,175 +2411,177 @@ function TeamsSection() {
|
||||
</div>
|
||||
) : (
|
||||
teams.map((tm) => (
|
||||
<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 ? (
|
||||
<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>
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
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={() => 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>
|
||||
)}
|
||||
</Fragment>
|
||||
)}
|
||||
</Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
@@ -2861,9 +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">IP</th>
|
||||
<th className="text-start px-3 py-2 font-medium text-muted-foreground">
|
||||
{t.settings.auditLog.tableHeaderAction}
|
||||
</th>
|
||||
|
||||
@@ -7,6 +7,7 @@ interface AuthState {
|
||||
authEnabled: boolean;
|
||||
isAuthenticated: boolean;
|
||||
mustChangePassword: boolean;
|
||||
mfaRequired: boolean;
|
||||
role: string | null;
|
||||
permissions: string[];
|
||||
analyticsEnabled: boolean | null;
|
||||
@@ -44,6 +45,7 @@ export function useAuth() {
|
||||
authEnabled: false,
|
||||
isAuthenticated: false,
|
||||
mustChangePassword: false,
|
||||
mfaRequired: false,
|
||||
role: null,
|
||||
permissions: [],
|
||||
analyticsEnabled: null,
|
||||
@@ -73,6 +75,7 @@ export function useAuth() {
|
||||
authEnabled: false,
|
||||
isAuthenticated: true,
|
||||
mustChangePassword: false,
|
||||
mfaRequired: false,
|
||||
role: "admin",
|
||||
permissions: ANON_ADMIN_PERMISSIONS,
|
||||
analyticsEnabled: null,
|
||||
@@ -104,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,
|
||||
@@ -125,6 +129,7 @@ export function useAuth() {
|
||||
authEnabled: true,
|
||||
isAuthenticated: false,
|
||||
mustChangePassword: false,
|
||||
mfaRequired: false,
|
||||
role: null,
|
||||
permissions: [],
|
||||
analyticsEnabled: null,
|
||||
|
||||
@@ -133,6 +133,11 @@ export function LoginPage() {
|
||||
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 authError = searchParams.get("error");
|
||||
@@ -167,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) {
|
||||
@@ -181,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">
|
||||
@@ -219,48 +259,123 @@ export function LoginPage() {
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className={`space-y-4${ssoEnforced ? " opacity-60" : ""}`}>
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium mb-1 text-foreground">
|
||||
{t.auth.username}
|
||||
</label>
|
||||
{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>
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user