Locked/no-access users (403/denied path/expired session/trial ended) were trapped: NoAccessScreen had no logout control, and «Выйти» lived only inside UserMenu, which does not render on the no-access screen. They could not switch accounts without clearing browser basic-auth manually. - Extract logout() into shared `lib/logout.ts` (single source of truth). - UserMenu now imports it instead of a local copy (behaviour unchanged). - NoAccessScreen renders an always-available «Выйти» accent button for every variant. Mirrored across both frontends (tradein-mvp/frontend + main frontend) to keep the MIRROR invariant in sync.
128 lines
3.9 KiB
TypeScript
128 lines
3.9 KiB
TypeScript
"use client";
|
||
|
||
/**
|
||
* Полноэкранный «доступа нет» — показывается в двух случаях:
|
||
* 1. /api/v1/me вернул 403 (юзер аутентифицирован Caddy basic_auth,
|
||
* но его нет в `auth/roles.yaml`). Variant: "user".
|
||
* 2. Юзер открыл URL, которого нет в его allowed_paths (или есть в
|
||
* deny_paths). Variant: "path".
|
||
*
|
||
* Tone-of-voice (см. `.claude/rules/ui-microcopy.md`): нейтральный business,
|
||
* без эмодзи, без «Ой!». Просто констатация.
|
||
*/
|
||
|
||
import { Lock } from "lucide-react";
|
||
|
||
import { logout } from "@/lib/logout";
|
||
|
||
interface NoAccessScreenProps {
|
||
/** "user" — юзер вне ролей; "path" — путь вне scope */
|
||
variant: "user" | "path";
|
||
/** Текущий путь — используется только для variant="path" в caption */
|
||
path?: string;
|
||
}
|
||
|
||
export function NoAccessScreen({ variant, path }: NoAccessScreenProps) {
|
||
const title = "Доступа нет";
|
||
const subtitle =
|
||
variant === "user"
|
||
? "Учётная запись не привязана к роли. Обратитесь к администратору GenDesign — kopylov."
|
||
: "У вашей роли нет доступа к этому разделу. Вернитесь на главную или обратитесь к администратору.";
|
||
|
||
return (
|
||
<main
|
||
role="alert"
|
||
aria-live="polite"
|
||
style={{
|
||
minHeight: "100vh",
|
||
background: "var(--bg-app)",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
padding: 24,
|
||
fontFamily: "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
background: "var(--bg-card)",
|
||
border: "1px solid var(--border-card)",
|
||
borderRadius: 12,
|
||
padding: "32px 28px",
|
||
maxWidth: 480,
|
||
width: "100%",
|
||
textAlign: "left",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: 40,
|
||
height: 40,
|
||
background: "var(--accent-soft)",
|
||
borderRadius: 8,
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
marginBottom: 16,
|
||
}}
|
||
>
|
||
<Lock size={20} strokeWidth={1.5} color="var(--accent)" />
|
||
</div>
|
||
<h1
|
||
style={{
|
||
margin: "0 0 8px",
|
||
fontSize: 22,
|
||
fontWeight: 600,
|
||
color: "var(--fg-primary)",
|
||
lineHeight: 1.25,
|
||
}}
|
||
>
|
||
{title}
|
||
</h1>
|
||
<p
|
||
style={{
|
||
margin: 0,
|
||
fontSize: 14,
|
||
color: "var(--fg-secondary)",
|
||
lineHeight: 1.6,
|
||
}}
|
||
>
|
||
{subtitle}
|
||
</p>
|
||
{variant === "path" && path ? (
|
||
<p
|
||
style={{
|
||
margin: "12px 0 0",
|
||
fontSize: 12,
|
||
color: "var(--fg-tertiary)",
|
||
fontFamily: "ui-monospace, SFMono-Regular, monospace",
|
||
wordBreak: "break-all",
|
||
}}
|
||
>
|
||
{path}
|
||
</p>
|
||
) : null}
|
||
{/* Always-available logout — иначе locked/no-access юзер заперт:
|
||
UserMenu (где обычно «Выйти») на этом экране не рендерится. */}
|
||
<button
|
||
type="button"
|
||
onClick={() => logout()}
|
||
style={{
|
||
marginTop: 20,
|
||
background: "var(--accent)",
|
||
color: "#FFFFFF",
|
||
border: "none",
|
||
borderRadius: 8,
|
||
padding: "10px 16px",
|
||
fontSize: 14,
|
||
fontWeight: 500,
|
||
cursor: "pointer",
|
||
fontFamily: "inherit",
|
||
}}
|
||
>
|
||
Выйти
|
||
</button>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|