This commit is contained in:
2026-08-14 01:38:34 +02:00
parent a9395585f8
commit dc9ea628c1
12 changed files with 163 additions and 140 deletions
+1 -1
View File
@@ -104,7 +104,7 @@ export type MeResponse = {
/** Membership company to restore via company switcher revert. */
staff_home_company_id?: string;
staff_home_company?: Company | null;
/** Non-prod only: show header user-switch for platform admin/demo or while impersonating. */
/** Platform staff_role=admin (any env) or non-prod demo: show header user-switch. */
dev_user_switch?: boolean;
impersonating?: boolean;
impersonator?: ImpersonatorRef | null;
+14 -4
View File
@@ -183,11 +183,17 @@
});
const showAdminNav = $derived(
Boolean(me?.staff_access?.support_desk || shouldUnlockAllFeatures(me))
Boolean(
!me?.impersonating &&
!me?.staff_tenant_acting &&
(me?.staff_access?.support_desk || shouldUnlockAllFeatures(me))
)
);
/** Demo + full platform staff only — never unlock via legacy is_platform_admin when staff_access denies full_admin (A1/support). */
const unlockAllFeatures = $derived(shouldUnlockAllFeatures(me));
/** Demo + full platform staff only — never unlock while impersonating or staff-acting as a tenant. */
const unlockAllFeatures = $derived(
shouldUnlockAllFeatures(me) && !me?.impersonating && !me?.staff_tenant_acting
);
/**
* P1-15: platform-admin readiness strip on /admin only (API fail-closed).
@@ -222,7 +228,11 @@
});
const showHypercareAdminTriage = $derived(
Boolean(me?.staff_access?.support_desk || shouldUnlockAllFeatures(me))
Boolean(
!me?.impersonating &&
!me?.staff_tenant_acting &&
(me?.staff_access?.support_desk || shouldUnlockAllFeatures(me))
)
);
</script>
+65 -15
View File
@@ -74,9 +74,12 @@
let companiesTotal = $state(0);
let companiesOffset = $state(0);
let plans = $state<AdminBillingPlan[]>([]);
let devTools = $state(false);
let userOps = $state(false);
let staffRoleApiOk = $state(true);
let passwordOpen = $state(false);
let passwordUser = $state<AdminOrgUser | null>(null);
let passwordValue = $state("");
let roleOpen = $state(false);
let roleUser = $state<AdminOrgUser | null>(null);
let roleValue = $state<"" | PlatformStaffRole>("");
@@ -118,8 +121,8 @@
}
try {
await Promise.all([reloadUsers(), reloadCompanies(), loadPlans()]);
// Local-only password/impersonation helpers — never show in production builds.
devTools = !import.meta.env.PROD;
// Platform admins can set passwords + impersonate (API enforces staff_role=admin for switch).
userOps = true;
} catch (err) {
error = failureMessage(err, "Failed to load directory");
} finally {
@@ -372,23 +375,41 @@
}
}
async function setDevPassword(userId: string) {
busyUserId = userId;
function openPasswordDialog(user: AdminOrgUser) {
passwordUser = user;
passwordValue = "";
passwordOpen = true;
}
async function saveForcedPassword(event: Event) {
event.preventDefault();
if (!passwordUser) return;
const pwd = passwordValue.trim();
if (pwd.length < 8) {
error = "Password must be at least 8 characters";
return;
}
busyUserId = passwordUser.id;
error = "";
success = "";
try {
const res = await api<{ email?: string }>(`/api/admin/users/${userId}/dev-password`, {
const res = await api<{ email?: string }>(`/api/admin/users/${passwordUser.id}/dev-password`, {
method: "POST",
body: {}
body: { password: pwd }
});
success = i18n.t("flash.admin.localPasswordSet", { email: res.email ?? "user" });
users = users.map((u) => (u.id === userId ? { ...u, must_set_password: false } : u));
success = i18n.t("flash.admin.localPasswordSet", { email: res.email ?? passwordUser.email });
users = users.map((u) =>
u.id === passwordUser!.id ? { ...u, must_set_password: false } : u
);
passwordOpen = false;
passwordUser = null;
passwordValue = "";
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
devTools = false;
userOps = false;
error = i18n.t("flash.admin.localPasswordUnavailable");
} else {
error = failureMessage(err, "Could not set local password");
error = failureMessage(err, "Could not set password");
}
} finally {
busyUserId = null;
@@ -405,7 +426,7 @@
window.location.assign("/dashboard");
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
devTools = false;
userOps = false;
error = i18n.t("flash.admin.switchUnavailable");
} else {
error = failureMessage(err, "Could not switch user");
@@ -579,13 +600,13 @@
<span class="hidden lg:inline">{i18n.t("admin.users.reissueInvite")}</span>
</Button>
{/if}
{#if devTools}
{#if userOps}
<Button
size="sm"
variant="outline"
loading={busyUserId === user.id}
onclick={() => setDevPassword(user.id)}
aria-label={`Set local password for ${user.email}`}
onclick={() => openPasswordDialog(user)}
aria-label={`Set password for ${user.email}`}
>
<KeyRound class="h-3.5 w-3.5 lg:mr-1" aria-hidden="true" />
<span class="hidden lg:inline">{i18n.t("admin.users.setLocalPassword")}</span>
@@ -735,6 +756,35 @@
{/if}
</PageShell>
<Dialog
bind:open={passwordOpen}
title="Set password"
description="Force-set a login password for this user (works for fake/legacy emails that cannot receive invites)."
>
<form class="space-y-4" onsubmit={saveForcedPassword}>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
{#if passwordUser}
<p class="text-sm text-muted-foreground">
{passwordUser.name || "—"} · {passwordUser.email}
</p>
{/if}
<div class="space-y-2">
<Label for="forced-password">New password</Label>
<Input
id="forced-password"
type="password"
autocomplete="new-password"
minlength={8}
required
bind:value={passwordValue}
/>
</div>
<Button type="submit" loading={busyUserId === passwordUser?.id}>Set password</Button>
</form>
</Dialog>
<Dialog
bind:open={roleOpen}
title={i18n.t("admin.users.assignRoleTitle")}