feat: Implement Supabase authentication, account management UI, and entitlement services.
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
.page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
padding: 34px 20px 28px;
|
||||
color: #e2ecff;
|
||||
background:
|
||||
radial-gradient(circle at 8% 8%, rgba(56, 189, 248, 0.24), transparent 38%),
|
||||
radial-gradient(circle at 92% 10%, rgba(45, 212, 191, 0.2), transparent 34%),
|
||||
radial-gradient(circle at 50% 120%, rgba(14, 165, 233, 0.26), transparent 40%),
|
||||
#050b16;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.aurora {
|
||||
position: absolute;
|
||||
inset: -10% -5% auto;
|
||||
height: 340px;
|
||||
background: linear-gradient(
|
||||
95deg,
|
||||
rgba(56, 189, 248, 0.2) 0%,
|
||||
rgba(45, 212, 191, 0.12) 46%,
|
||||
rgba(99, 102, 241, 0.08) 100%
|
||||
);
|
||||
filter: blur(58px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gridNoise {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(148, 163, 184, 0.06) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148, 163, 184, 0.06) 1px, transparent 1px);
|
||||
background-size: 42px 42px;
|
||||
mask-image: radial-gradient(circle at 50% 25%, black, transparent 72%);
|
||||
pointer-events: none;
|
||||
opacity: 0.22;
|
||||
}
|
||||
|
||||
.shell {
|
||||
position: relative;
|
||||
width: min(1120px, 100%);
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.topBar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(15, 23, 42, 0.76);
|
||||
backdrop-filter: blur(14px);
|
||||
border-radius: 16px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.brandBlock {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: clamp(24px, 2.2vw, 30px);
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
color: #f8fbff;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #8ea3c9;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ghostBtn,
|
||||
.primaryBtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
padding: 0 12px;
|
||||
color: #dce8ff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.22s ease;
|
||||
background: rgba(15, 23, 42, 0.5);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghostBtn:hover,
|
||||
.primaryBtn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ghostBtn:hover {
|
||||
border-color: rgba(45, 212, 191, 0.45);
|
||||
color: #f8fcff;
|
||||
background: rgba(45, 212, 191, 0.12);
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
border-color: rgba(56, 189, 248, 0.52);
|
||||
background: linear-gradient(135deg, rgba(14, 116, 144, 0.88), rgba(6, 182, 212, 0.74));
|
||||
color: #f6fcff;
|
||||
box-shadow: 0 12px 26px rgba(8, 47, 73, 0.35);
|
||||
}
|
||||
|
||||
.primaryBtn:hover {
|
||||
border-color: rgba(103, 232, 249, 0.72);
|
||||
box-shadow: 0 16px 30px rgba(14, 116, 144, 0.42);
|
||||
}
|
||||
|
||||
.heroCard {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
border: 1px solid rgba(56, 189, 248, 0.24);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(14, 116, 144, 0.17) 0%, rgba(15, 23, 42, 0.86) 58%),
|
||||
rgba(15, 23, 42, 0.8);
|
||||
border-radius: 18px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
color: #e7fbff;
|
||||
border: 1px solid rgba(103, 232, 249, 0.45);
|
||||
background: linear-gradient(135deg, rgba(6, 182, 212, 0.65), rgba(59, 130, 246, 0.56));
|
||||
box-shadow: 0 18px 38px rgba(2, 132, 199, 0.28);
|
||||
}
|
||||
|
||||
.heroMain {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.heroMain h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(20px, 1.9vw, 28px);
|
||||
letter-spacing: -0.02em;
|
||||
color: #f8fcff;
|
||||
}
|
||||
|
||||
.heroMain p {
|
||||
margin: 0;
|
||||
color: #a7bcdd;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.badgeWarn,
|
||||
.badgeGhost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 5px 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
border: 1px solid rgba(34, 197, 94, 0.45);
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.badgeWarn {
|
||||
border: 1px solid rgba(245, 158, 11, 0.46);
|
||||
background: rgba(245, 158, 11, 0.13);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.badgeGhost {
|
||||
border: 1px solid rgba(148, 163, 184, 0.34);
|
||||
background: rgba(148, 163, 184, 0.12);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.updatedText {
|
||||
justify-self: end;
|
||||
font-size: 12px;
|
||||
color: #8ea3c9;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.noticeRow,
|
||||
.errorRow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 12px;
|
||||
padding: 11px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.noticeRow {
|
||||
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||
background: rgba(15, 23, 42, 0.75);
|
||||
color: #bae6fd;
|
||||
}
|
||||
|
||||
.errorRow {
|
||||
border: 1px solid rgba(248, 113, 113, 0.44);
|
||||
background: rgba(127, 29, 29, 0.34);
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card,
|
||||
.cardWide {
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 14px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.84), rgba(15, 23, 42, 0.72)),
|
||||
rgba(10, 15, 30, 0.78);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cardWide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.card h3,
|
||||
.cardWide h3 {
|
||||
margin: 0 0 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 15px;
|
||||
color: #e6f0ff;
|
||||
}
|
||||
|
||||
.metaList {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.metaList > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
border-bottom: 1px dashed rgba(148, 163, 184, 0.22);
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
|
||||
.metaList > div:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.metaList dt {
|
||||
font-size: 12px;
|
||||
color: #89a1c8;
|
||||
}
|
||||
|
||||
.metaList dd {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #eff6ff;
|
||||
max-width: 62%;
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: "Consolas", "SFMono-Regular", "Menlo", monospace;
|
||||
font-size: 12px !important;
|
||||
color: #c7d2fe !important;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
color: #9ab0d2;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.commandRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.command {
|
||||
flex: 1 1 420px;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(103, 232, 249, 0.24);
|
||||
background: rgba(8, 47, 73, 0.32);
|
||||
color: #d1f9ff;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.copyBtn {
|
||||
height: 36px;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(45, 212, 191, 0.48);
|
||||
background: rgba(20, 184, 166, 0.12);
|
||||
color: #99f6e4;
|
||||
padding: 0 12px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.copyBtn:hover {
|
||||
background: rgba(20, 184, 166, 0.22);
|
||||
border-color: rgba(94, 234, 212, 0.78);
|
||||
color: #ecfeff;
|
||||
}
|
||||
|
||||
.spin {
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.topBar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.heroCard {
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.updatedText {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.page {
|
||||
padding: 18px 12px 20px;
|
||||
}
|
||||
|
||||
.topBar,
|
||||
.heroCard,
|
||||
.card,
|
||||
.cardWide {
|
||||
border-radius: 12px;
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.ghostBtn,
|
||||
.primaryBtn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
Copy,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
LogOut,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
UserCircle2,
|
||||
} from "lucide-react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import {
|
||||
getSupabaseBrowserClient,
|
||||
hasSupabasePublicEnv,
|
||||
} from "@/lib/supabase/client";
|
||||
import styles from "./AccountCenter.module.css";
|
||||
|
||||
type AuthMeResponse = {
|
||||
authenticated?: boolean;
|
||||
user_id?: string | null;
|
||||
email?: string | null;
|
||||
entitlement_mode?: string | null;
|
||||
subscription_required?: boolean;
|
||||
subscription_active?: boolean | null;
|
||||
};
|
||||
|
||||
function formatTime(value: string | undefined | null, locale: string) {
|
||||
if (!value) return "";
|
||||
try {
|
||||
const dt = new Date(value);
|
||||
if (Number.isNaN(dt.getTime())) return "";
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(dt);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeProvider(user: User | null) {
|
||||
const provider = String(user?.app_metadata?.provider || "").trim().toLowerCase();
|
||||
if (provider) return provider;
|
||||
const providers = user?.app_metadata?.providers;
|
||||
if (Array.isArray(providers) && providers.length) {
|
||||
return String(providers[0] || "").trim().toLowerCase();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function AccountCenter() {
|
||||
const { locale, t } = useI18n();
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [updatedAt, setUpdatedAt] = useState<string>("");
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [backend, setBackend] = useState<AuthMeResponse | null>(null);
|
||||
|
||||
const supabaseReady = hasSupabasePublicEnv();
|
||||
|
||||
const loadSnapshot = useCallback(async () => {
|
||||
setErrorText("");
|
||||
try {
|
||||
const userPromise = supabaseReady
|
||||
? getSupabaseBrowserClient().auth.getUser()
|
||||
: Promise.resolve({ data: { user: null as User | null } });
|
||||
const backendPromise = fetch("/api/auth/me", { cache: "no-store" });
|
||||
|
||||
const [userResult, backendResult] = await Promise.all([
|
||||
userPromise,
|
||||
backendPromise,
|
||||
]);
|
||||
|
||||
setUser(userResult.data?.user ?? null);
|
||||
|
||||
if (!backendResult.ok) {
|
||||
const raw = (await backendResult.text()).slice(0, 240);
|
||||
throw new Error(`HTTP ${backendResult.status} ${raw}`.trim());
|
||||
}
|
||||
const backendJson = (await backendResult.json()) as AuthMeResponse;
|
||||
setBackend(backendJson);
|
||||
setUpdatedAt(new Date().toISOString());
|
||||
} catch (error) {
|
||||
setErrorText(String(error));
|
||||
}
|
||||
}, [supabaseReady]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
setLoading(true);
|
||||
await loadSnapshot();
|
||||
if (!cancelled) setLoading(false);
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loadSnapshot]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await loadSnapshot();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const onSignOut = async () => {
|
||||
if (supabaseReady) {
|
||||
try {
|
||||
const supabase = getSupabaseBrowserClient();
|
||||
await supabase.auth.signOut();
|
||||
} catch {}
|
||||
}
|
||||
router.replace("/auth/login");
|
||||
};
|
||||
|
||||
const userId = backend?.user_id || user?.id || "";
|
||||
const email = backend?.email || user?.email || "";
|
||||
const providerRaw = normalizeProvider(user);
|
||||
const provider = providerRaw ? providerRaw.toUpperCase() : t("account.na");
|
||||
const lastSignIn = formatTime(user?.last_sign_in_at, locale) || t("account.na");
|
||||
const updatedAtLabel = formatTime(updatedAt, locale) || t("account.na");
|
||||
const displayName =
|
||||
String(user?.user_metadata?.full_name || "").trim() ||
|
||||
(email ? String(email).split("@")[0] : "") ||
|
||||
t("account.guestName");
|
||||
const initials = displayName.slice(0, 2).toUpperCase();
|
||||
|
||||
const modeLabel = useMemo(() => {
|
||||
const mode = String(backend?.entitlement_mode || "").trim().toLowerCase();
|
||||
if (mode === "supabase") return t("account.mode.supabase");
|
||||
if (mode === "legacy_token") return t("account.mode.legacy");
|
||||
if (mode === "disabled") return t("account.mode.disabled");
|
||||
return t("account.mode.unknown");
|
||||
}, [backend?.entitlement_mode, t]);
|
||||
|
||||
const subscriptionLabel = useMemo(() => {
|
||||
if (!backend?.subscription_required) return t("account.subscription.notRequired");
|
||||
if (backend.subscription_active === true) return t("account.subscription.active");
|
||||
if (backend.subscription_active === false) return t("account.subscription.inactive");
|
||||
return t("account.subscription.unknown");
|
||||
}, [backend?.subscription_active, backend?.subscription_required, t]);
|
||||
|
||||
const bindCommand = userId
|
||||
? `/bind ${userId}${email ? ` ${email}` : ""}`
|
||||
: "/bind <supabase_user_id> <email>";
|
||||
|
||||
const copyBindCommand = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(bindCommand);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1300);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<div className={styles.aurora} />
|
||||
<div className={styles.gridNoise} />
|
||||
<div className={styles.shell}>
|
||||
<header className={styles.topBar}>
|
||||
<div className={styles.brandBlock}>
|
||||
<h1 className={styles.title}>{t("account.title")}</h1>
|
||||
<p className={styles.subtitle}>{t("account.subtitle")}</p>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Link className={styles.ghostBtn} href="/">
|
||||
<ArrowLeft size={15} />
|
||||
{t("account.backDashboard")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ghostBtn}
|
||||
onClick={() => void onRefresh()}
|
||||
disabled={refreshing || loading}
|
||||
>
|
||||
{refreshing ? <Loader2 size={15} className={styles.spin} /> : <RefreshCw size={15} />}
|
||||
{t("account.refresh")}
|
||||
</button>
|
||||
<button type="button" className={styles.primaryBtn} onClick={() => void onSignOut()}>
|
||||
<LogOut size={15} />
|
||||
{t("account.signOut")}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className={styles.heroCard}>
|
||||
<div className={styles.avatar}>{initials || "PW"}</div>
|
||||
<div className={styles.heroMain}>
|
||||
<h2>{displayName}</h2>
|
||||
<p>{email || t("account.na")}</p>
|
||||
<div className={styles.badges}>
|
||||
<span className={styles.badge}>
|
||||
<CheckCircle2 size={14} />
|
||||
{t("account.authenticated")}
|
||||
</span>
|
||||
{backend?.subscription_active ? (
|
||||
<span className={styles.badge}>
|
||||
<ShieldCheck size={14} />
|
||||
{t("account.subscriptionActive")}
|
||||
</span>
|
||||
) : backend?.subscription_required ? (
|
||||
<span className={styles.badgeWarn}>
|
||||
<KeyRound size={14} />
|
||||
{t("account.subscriptionRequired")}
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.badgeGhost}>
|
||||
<ShieldCheck size={14} />
|
||||
{t("account.subscriptionUnknown")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.updatedText}>{t("account.updatedAt", { time: updatedAtLabel })}</div>
|
||||
</section>
|
||||
|
||||
{loading ? (
|
||||
<section className={styles.noticeRow}>
|
||||
<Loader2 size={16} className={styles.spin} />
|
||||
<span>{t("account.loading")}</span>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{errorText ? (
|
||||
<section className={styles.errorRow}>
|
||||
{t("account.error", { message: errorText })}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className={styles.cards}>
|
||||
<article className={styles.card}>
|
||||
<h3>
|
||||
<ShieldCheck size={17} />
|
||||
{t("account.card.membership")}
|
||||
</h3>
|
||||
<dl className={styles.metaList}>
|
||||
<div>
|
||||
<dt>{t("account.field.mode")}</dt>
|
||||
<dd>{modeLabel}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("account.field.backendStatus")}</dt>
|
||||
<dd>
|
||||
{backend?.authenticated
|
||||
? t("account.backend.ok")
|
||||
: t("account.backend.fail")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("account.field.requirement")}</dt>
|
||||
<dd>
|
||||
{backend?.subscription_required
|
||||
? t("account.subscriptionRequired")
|
||||
: t("account.subscription.notRequired")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("account.field.subscription")}</dt>
|
||||
<dd>{subscriptionLabel}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article className={styles.card}>
|
||||
<h3>
|
||||
<UserCircle2 size={17} />
|
||||
{t("account.card.identity")}
|
||||
</h3>
|
||||
<dl className={styles.metaList}>
|
||||
<div>
|
||||
<dt>{t("account.field.email")}</dt>
|
||||
<dd>{email || t("account.na")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("account.field.userId")}</dt>
|
||||
<dd className={styles.mono}>{userId || t("account.na")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("account.field.provider")}</dt>
|
||||
<dd>{provider}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t("account.field.lastSignIn")}</dt>
|
||||
<dd>{lastSignIn}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
|
||||
<article className={styles.cardWide}>
|
||||
<h3>
|
||||
<Bot size={17} />
|
||||
{t("account.card.bot")}
|
||||
</h3>
|
||||
<p className={styles.hint}>{t("account.field.bindHint")}</p>
|
||||
<div className={styles.commandRow}>
|
||||
<code className={styles.command}>{bindCommand}</code>
|
||||
<button type="button" className={styles.copyBtn} onClick={() => void copyBindCommand()}>
|
||||
<Copy size={14} />
|
||||
{copied ? t("account.copied") : t("account.copy")}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{!supabaseReady ? (
|
||||
<section className={styles.noticeRow}>
|
||||
<KeyRound size={15} />
|
||||
<span>NEXT_PUBLIC_SUPABASE_URL / ANON_KEY is not configured.</span>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user