Add multi-method login UI and document auth method contracts.

Surface Google, email, phone, and Telegram on login (mocked), polish the brand panel and fixes for Button/Menu, and keep the backend spec sheet aligned.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kirukib 2026-08-07 16:12:43 +03:00
parent 8fe6d58a09
commit f2e992e0bd
11 changed files with 392 additions and 110 deletions

View File

@ -28,10 +28,10 @@ For single-org sessions, `orgId` is implicit from JWT/session. Multi-org users (
| Mode (this phase) | Description | | Mode (this phase) | Description |
|-------------------|-------------| |-------------------|-------------|
| Mock demo | `/login` **test-user dropdown** (`demo_user_id`) sets session cookie with user + roles + org; legacy `demo_role` still accepted | | Mock demo | `/login` offers **Google, email, phone, Telegram** (UI mocked) plus a **test-user dropdown**; any path sets session via `demo_user_id` |
| Production (future) | OAuth2 / magic link / SSO — TBD with platform auth | | Production (future) | Google OIDC, email magic-link/OTP, SMS OTP, Telegram Login Widget / Mini App — see [`endpoints/auth.md`](endpoints/auth.md) |
Demo personas: `user_super`, `user_hr`, `user_fin`, `user_mem` — see [`endpoints/auth.md`](endpoints/auth.md) (`GET /v1/auth/demo-profiles`). Demo personas: `user_super`, `user_hr`, `user_fin`, `user_mem` — see `GET /v1/auth/demo-profiles`.
### Session shape ### Session shape

View File

@ -8,7 +8,7 @@ REST-style API contract for Gishen-B2B. All paths prefixed with `/v1` unless not
| Group | Doc | Description | | Group | Doc | Description |
|-------|-----|-------------| |-------|-----|-------------|
| Auth | [auth.md](auth.md) | Demo login personas, session, locale, `/me/profile` | | Auth | [auth.md](auth.md) | Demo login + planned Google/email/phone/Telegram, session, locale, `/me/profile` |
| Organisation | [org.md](org.md) | Org profile, registration, departments, verification | | Organisation | [org.md](org.md) | Org profile, registration, departments, verification |
| Members | [members.md](members.md) | CRUD, import, invites, join, pharmacy ID card | | Members | [members.md](members.md) | CRUD, import, invites, join, pharmacy ID card |
| Packages | [packages.md](packages.md) | Benefit plans | | Packages | [packages.md](packages.md) | Benefit plans |

View File

@ -31,6 +31,30 @@ Legacy: `{ "demo_role": "HR_ADMIN", "locale": "en" }`
Production (future): `{ "email", "password" }` or OAuth code exchange. Production (future): `{ "email", "password" }` or OAuth code exchange.
### Planned auth methods (UI present; production TBD)
| Method | Future flow | Mock phase |
|--------|-------------|------------|
| Google | OAuth 2.0 / OIDC | Button → toast → demo persona session |
| Email | Magic link or password OTP | Same |
| Phone | SMS OTP (E.164) | Same |
| Telegram | Telegram Login Widget / Mini App deep link | Same |
Demo continues to use `demo_user_id` until platform auth is wired.
### Future endpoints (stubs)
| Method | Path | Notes |
|--------|------|-------|
| `GET` | `/v1/auth/oauth/google/start` | Redirect to Google; callback exchanges code |
| `POST` | `/v1/auth/email/otp/start` | Body `{ "email" }` — send OTP / magic link |
| `POST` | `/v1/auth/email/otp/verify` | Body `{ "email", "code" }` — returns session |
| `POST` | `/v1/auth/phone/otp/start` | Body `{ "phone" }` E.164 |
| `POST` | `/v1/auth/phone/otp/verify` | Body `{ "phone", "code" }` |
| `POST` | `/v1/auth/telegram/callback` | Telegram Login Widget payload verification |
All production methods return the same session envelope as `POST /v1/auth/login`.
### Response 200 ### Response 200
```json ```json
@ -279,7 +303,7 @@ Updated profile object.
| Route | Purpose | | Route | Purpose |
|-------|---------| |-------|---------|
| `/login` | Yimaru-style login — test-user dropdown + locale switcher | | `/login` | Sign-in methods (Google, email, phone, Telegram — mocked) + demo persona dropdown + locale |
| `/profile` | Own profile — avatar, org, role switch, sign out | | `/profile` | Own profile — avatar, org, role switch, sign out |
## Clinical withhold notes ## Clinical withhold notes

View File

@ -10,7 +10,7 @@ Feature → pages → entities → endpoints mapping for Gishen-B2B §3 features
| Feature | Pages | Entities | Endpoints | Feature doc | | Feature | Pages | Entities | Endpoints | Feature doc |
|---------|-------|----------|-----------|---------------| |---------|-------|----------|-----------|---------------|
| Auth & roles | `/login` | SessionUser | `auth.md` | [auth-roles.md](auth-roles.md) | | Auth & roles | `/login` (Google / email / phone / Telegram UI + demo personas) | SessionUser | `auth.md` | [auth-roles.md](auth-roles.md) |
| Profile | `/profile` | SessionUser | `auth.md` (`/v1/me/profile`) | [profile.md](profile.md) | | Profile | `/profile` | SessionUser | `auth.md` (`/v1/me/profile`) | [profile.md](profile.md) |
| Quick search | Portal topbar (Cmd/Ctrl+K) | — | — | [quick-search.md](quick-search.md) | | Quick search | Portal topbar (Cmd/Ctrl+K) | — | — | [quick-search.md](quick-search.md) |
| Portal layout | All portal (PageShell, DetailPageLayout, Bc, StatCard, Tabs) | — | — | [portal-layout.md](portal-layout.md) | | Portal layout | All portal (PageShell, DetailPageLayout, Bc, StatCard, Tabs) | — | — | [portal-layout.md](portal-layout.md) |

View File

@ -8,7 +8,7 @@ Demo login with role selector, session management, and role-filtered navigation.
| Route | Purpose | | Route | Purpose |
|-------|---------| |-------|---------|
| `/login` | Yimaru-style split login; **test-user dropdown** (demo personas) + locale switcher; register/join CTAs | | `/login` | Sign-in methods (Google, email, phone, Telegram — **mocked**) + demo persona dropdown + locale switcher; register/join CTAs |
| `/profile` | Own account — see [profile.md](profile.md) | | `/profile` | Own account — see [profile.md](profile.md) |
## Roles ## Roles
@ -34,11 +34,12 @@ Demo login with role selector, session management, and role-filtered navigation.
## Demo login UX ## Demo login UX
1. User picks a **test user** from the dropdown (not only a role): Selam Super, Hanna HR, Fikru Finance, Abebe Member. 1. Optional: **Google / Email / Phone / Telegram** (mock) — toast note, then signs in as the selected demo persona.
2. Optional locale toggle (en/am) on the login panel. 2. Or pick a **test user** from the dropdown: Selam Super, Hanna HR, Fikru Finance, Abebe Member.
3. Continue sets session via `loginAsUser(id)` and redirects to that user’s home path. 3. Optional locale toggle (en/am) on the login panel.
4. Topbar menu: Profile → `/profile`; "Switch demo user" → `/login`. 4. Continue / method buttons set session via `loginAsUser(id)` and redirect to that user’s home path.
5. Multi-role demo (`user_super`) can switch active role on `/profile`. 5. Topbar menu: Profile → `/profile`; "Switch demo user" → `/login`.
6. Multi-role demo (`user_super`) can switch active role on `/profile`.
Pre-seeded demo data: single mock org (`org_yaltopia` / Yaltopia) with realistic members, packages, spend. Pre-seeded demo data: single mock org (`org_yaltopia` / Yaltopia) with realistic members, packages, spend.

View File

@ -1,8 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useState, type ReactNode } from "react";
import Image from "next/image"; import Image from "next/image";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { brand } from "@/data/brand"; import { brand } from "@/data/brand";
import { useAuth } from "@/providers/auth-provider"; import { useAuth } from "@/providers/auth-provider";
import { Link, useRouter } from "@/i18n/routing"; import { Link, useRouter } from "@/i18n/routing";
@ -17,11 +18,143 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Building2, Handshake, KeyRound } from "lucide-react"; import {
ArrowRight,
Building2,
Handshake,
KeyRound,
Mail,
Package,
Phone,
Users,
Wallet,
type LucideIcon,
} from "lucide-react";
import { UserAvatar } from "@/components/shared/user-avatar"; import { UserAvatar } from "@/components/shared/user-avatar";
import { cn } from "@/lib/utils";
const PERSONAS = listDemoPersonas(); const PERSONAS = listDemoPersonas();
const SIDE_FEATURES: {
icon: LucideIcon;
key: "sideFeaturePeople" | "sideFeaturePackages" | "sideFeatureFinance";
}[] = [
{ icon: Users, key: "sideFeaturePeople" },
{ icon: Package, key: "sideFeaturePackages" },
{ icon: Wallet, key: "sideFeatureFinance" },
];
type AuthMethod = "google" | "email" | "phone" | "telegram";
function GoogleMark({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1Z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23Z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18A10.96 10.96 0 0 0 1 12c0 1.77.42 3.45 1.18 4.93l2.85-2.22.81-.62Z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53Z"
/>
</svg>
);
}
function TelegramMark({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden>
<path
fill="#2AABEE"
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm4.64 6.8-1.56 7.36c-.12.52-.42.65-.86.4l-2.38-1.76-1.15 1.1c-.13.13-.24.24-.49.24l.17-2.42 4.4-3.97c.19-.17-.04-.27-.3-.1l-5.44 3.43-2.34-.73c-.51-.16-.52-.51.11-.76l9.15-3.53c.42-.16.8.1.66.74Z"
/>
</svg>
);
}
function GetStartedLink({
href,
icon: Icon,
title,
hint,
primary,
}: {
href: string;
icon: LucideIcon;
title: string;
hint: string;
primary?: boolean;
}) {
return (
<Link
href={href}
className={cn(
"group flex items-center gap-3 rounded-xl border px-3.5 py-3 transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
primary
? "border-brand/40 bg-brand/10 hover:border-brand hover:bg-brand/15"
: "border-border bg-card hover:border-brand/35 hover:bg-muted/40",
)}
>
<span
className={cn(
"inline-flex size-10 shrink-0 items-center justify-center rounded-lg transition-colors",
primary
? "bg-brand text-foreground"
: "bg-muted text-brand-deep group-hover:bg-brand/20",
)}
>
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1 text-left">
<span className="block text-sm font-semibold text-foreground">{title}</span>
<span className="mt-0.5 block text-xs text-muted-foreground">{hint}</span>
</span>
<ArrowRight
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5",
primary && "text-brand-deep",
)}
/>
</Link>
);
}
function AuthMethodButton({
label,
icon,
onClick,
disabled,
}: {
label: string;
icon: ReactNode;
onClick: () => void;
disabled?: boolean;
}) {
return (
<Button
type="button"
variant="outline"
disabled={disabled}
onClick={onClick}
className="h-11 justify-center gap-2.5 rounded-xl border-border bg-card font-medium hover:bg-muted/50"
>
<span className="inline-flex size-5 shrink-0 items-center justify-center [&_svg]:size-5">
{icon}
</span>
{label}
</Button>
);
}
export default function LoginPage() { export default function LoginPage() {
const t = useTranslations(); const t = useTranslations();
const { loginAsUser } = useAuth(); const { loginAsUser } = useAuth();
@ -31,20 +164,22 @@ export default function LoginPage() {
const selected = PERSONAS.find((p) => p.id === personaId) ?? PERSONAS[0]; const selected = PERSONAS.find((p) => p.id === personaId) ?? PERSONAS[0];
function handleContinue() { function completeLogin() {
if (!selected) return; if (!selected) return;
setSubmitting(true); setSubmitting(true);
const next = loginAsUser(selected.id); const next = loginAsUser(selected.id);
router.push(ROLE_META[next.activeRole].homePath); router.push(ROLE_META[next.activeRole].homePath);
} }
function handleMethod(method: AuthMethod) {
const methodLabel = t(`login.${method}`);
toast.message(t("login.demoMethodNote", { method: methodLabel }));
completeLogin();
}
return ( return (
<div className="relative flex min-h-screen overflow-hidden bg-[#f6f7f2]"> <div className="relative flex min-h-screen overflow-hidden bg-[#f6f7f2]">
{/* Atmospheric background */} <div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
<div
aria-hidden
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<div className="absolute -left-24 -top-28 h-[28rem] w-[28rem] rounded-full bg-[color-mix(in_srgb,var(--brand)_22%,transparent)] blur-3xl" /> <div className="absolute -left-24 -top-28 h-[28rem] w-[28rem] rounded-full bg-[color-mix(in_srgb,var(--brand)_22%,transparent)] blur-3xl" />
<div className="absolute -bottom-32 -right-20 h-[32rem] w-[32rem] rounded-full bg-[color-mix(in_srgb,var(--brand-deep)_14%,transparent)] blur-3xl" /> <div className="absolute -bottom-32 -right-20 h-[32rem] w-[32rem] rounded-full bg-[color-mix(in_srgb,var(--brand-deep)_14%,transparent)] blur-3xl" />
<div <div
@ -57,33 +192,78 @@ export default function LoginPage() {
/> />
</div> </div>
{/* Left brand panel — Yimaru-style split */} <aside className="relative hidden lg:flex lg:w-1/2 xl:w-[52%] flex-col justify-between overflow-hidden bg-[#141414] px-12 py-14 xl:px-16">
<div className="relative hidden lg:flex lg:w-1/2 xl:w-[55%] items-center justify-center overflow-hidden bg-gradient-to-br from-[#1a2610] via-[#2a3d18] to-[color-mix(in_srgb,var(--brand)_55%,#1a2610)]">
<div aria-hidden className="absolute inset-0 overflow-hidden"> <div aria-hidden className="absolute inset-0 overflow-hidden">
<div className="absolute -left-20 -top-20 h-96 w-96 rounded-full bg-white/5" /> <div className="absolute inset-0 bg-gradient-to-br from-[#1c2a12] via-[#141414] to-[#0d1208]" />
<div className="absolute -bottom-32 -right-16 h-[500px] w-[500px] rounded-full bg-white/5" /> <div className="absolute -left-24 top-1/4 h-[28rem] w-[28rem] rounded-full bg-[color-mix(in_srgb,var(--brand)_28%,transparent)] blur-3xl animate-in fade-in duration-1000" />
<div className="absolute left-1/3 top-1/4 h-64 w-64 rounded-full bg-[color-mix(in_srgb,var(--brand)_25%,transparent)]" /> <div className="absolute -right-20 bottom-0 h-[22rem] w-[22rem] rounded-full bg-[color-mix(in_srgb,var(--brand-deep)_18%,transparent)] blur-3xl" />
<div
className="absolute inset-0 opacity-[0.07]"
style={{
backgroundImage:
"linear-gradient(to right, #fff 1px, transparent 1px), linear-gradient(to bottom, #fff 1px, transparent 1px)",
backgroundSize: "48px 48px",
maskImage: "radial-gradient(ellipse at 30% 40%, black 20%, transparent 70%)",
}}
/>
</div> </div>
<div className="relative z-10 max-w-md px-12 text-center animate-in fade-in slide-in-from-left-4 duration-700">
<div className="relative z-10 animate-in fade-in slide-in-from-left-3 duration-700">
<div className="inline-flex items-center gap-3">
<Image <Image
src={brand.logo} src={brand.logo}
alt={brand.shortName} alt={brand.shortName}
width={88} width={48}
height={88} height={48}
className="mx-auto mb-8 rounded-2xl bg-white/95 p-2 shadow-lg" className="rounded-xl bg-white p-1 shadow-md"
priority priority
/> />
<p className="font-display text-3xl font-semibold tracking-tight text-white"> <div>
<p className="font-display text-lg font-semibold tracking-tight text-white">
{brand.shortName}
</p>
<p className="text-[11px] tracking-wide text-white/50 uppercase">
{t("common.appName")} {t("common.appName")}
</p> </p>
<p className="mt-3 text-base leading-relaxed text-white/70"> </div>
{t("login.brandBlurb")}
</p>
</div> </div>
</div> </div>
{/* Right form panel */} <div className="relative z-10 max-w-lg animate-in fade-in slide-in-from-left-4 duration-700">
<div className="relative flex w-full flex-col items-center justify-center px-6 py-12 lg:w-1/2 xl:w-[45%]"> <p className="mb-4 text-xs font-medium tracking-[0.18em] text-brand uppercase">
{t("login.eyebrow")}
</p>
<h2 className="font-display text-4xl font-semibold leading-[1.15] tracking-tight text-white xl:text-[2.75rem]">
{t("login.brandHeadline")}
</h2>
<p className="mt-4 max-w-md text-base leading-relaxed text-white/65">
{t("login.brandBlurb")}
</p>
<ul className="mt-10 space-y-4">
{SIDE_FEATURES.map(({ icon: Icon, key }, i) => (
<li
key={key}
className="flex items-start gap-3.5 animate-in fade-in slide-in-from-left-2"
style={{ animationDelay: `${200 + i * 90}ms`, animationDuration: "600ms" }}
>
<span className="mt-0.5 inline-flex size-9 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-white/5 text-brand">
<Icon className="size-4" />
</span>
<span className="pt-1.5 text-sm leading-snug text-white/80">
{t(`login.${key}`)}
</span>
</li>
))}
</ul>
</div>
<p className="relative z-10 text-xs text-white/35">
{brand.address} · {brand.b2bEmail}
</p>
</aside>
<div className="relative flex w-full flex-col items-center justify-center px-6 py-12 lg:w-1/2 xl:w-[48%]">
<div className="absolute right-4 top-4 sm:right-6 sm:top-6"> <div className="absolute right-4 top-4 sm:right-6 sm:top-6">
<LanguageSwitcher className="w-[130px]" /> <LanguageSwitcher className="w-[130px]" />
</div> </div>
@ -101,7 +281,7 @@ export default function LoginPage() {
</div> </div>
<div className="mb-8"> <div className="mb-8">
<p className="mb-1.5 text-sm font-medium uppercase tracking-widest text-brand-deep"> <p className="mb-1.5 text-sm font-medium tracking-widest text-brand-deep uppercase">
{t("login.eyebrow")} {t("login.eyebrow")}
</p> </p>
<h1 className="mb-2 font-display text-3xl font-semibold tracking-tight text-foreground"> <h1 className="mb-2 font-display text-3xl font-semibold tracking-tight text-foreground">
@ -113,6 +293,49 @@ export default function LoginPage() {
</div> </div>
<div className="space-y-5 rounded-2xl border border-border bg-card/95 p-6 shadow-none backdrop-blur-sm"> <div className="space-y-5 rounded-2xl border border-border bg-card/95 p-6 shadow-none backdrop-blur-sm">
<div className="space-y-3">
<p className="text-[11px] font-medium tracking-wider text-muted-foreground uppercase">
{t("login.signInWith")}
</p>
<div className="grid grid-cols-2 gap-2.5">
<AuthMethodButton
label={t("login.google")}
icon={<GoogleMark />}
disabled={submitting || !selected}
onClick={() => handleMethod("google")}
/>
<AuthMethodButton
label={t("login.email")}
icon={<Mail className="size-5 text-foreground" />}
disabled={submitting || !selected}
onClick={() => handleMethod("email")}
/>
<AuthMethodButton
label={t("login.phone")}
icon={<Phone className="size-5 text-foreground" />}
disabled={submitting || !selected}
onClick={() => handleMethod("phone")}
/>
<AuthMethodButton
label={t("login.telegram")}
icon={<TelegramMark />}
disabled={submitting || !selected}
onClick={() => handleMethod("telegram")}
/>
</div>
</div>
<div className="relative py-1">
<div className="absolute inset-0 flex items-center" aria-hidden>
<div className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center">
<span className="bg-card px-3 text-[11px] font-medium tracking-wide text-muted-foreground uppercase">
{t("login.orDemo")}
</span>
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="demo-user">{t("login.selectUser")}</Label> <Label htmlFor="demo-user">{t("login.selectUser")}</Label>
<Select <Select
@ -137,9 +360,7 @@ export default function LoginPage() {
fallbackClassName="bg-brand/20 text-[10px] font-semibold" fallbackClassName="bg-brand/20 text-[10px] font-semibold"
/> />
<span className="flex min-w-0 flex-col gap-0.5"> <span className="flex min-w-0 flex-col gap-0.5">
<span className="font-medium"> <span className="font-medium">{t(p.personaLabelKey)}</span>
{t(p.personaLabelKey)}
</span>
<span className="truncate text-xs text-muted-foreground"> <span className="truncate text-xs text-muted-foreground">
{p.name} · {p.email} {p.name} · {p.email}
</span> </span>
@ -159,7 +380,7 @@ export default function LoginPage() {
<Button <Button
className="h-11 w-full rounded-xl text-sm font-semibold" className="h-11 w-full rounded-xl text-sm font-semibold"
disabled={submitting || !selected} disabled={submitting || !selected}
onClick={handleContinue} onClick={completeLogin}
> >
{selected {selected
? t("login.continueAs", { ? t("login.continueAs", {
@ -169,35 +390,31 @@ export default function LoginPage() {
</Button> </Button>
</div> </div>
<div className="mt-6 space-y-2"> <div className="mt-8">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground"> <div className="mb-3">
{t("login.getStarted")} <p className="text-sm font-semibold text-foreground">{t("login.getStarted")}</p>
</p> <p className="mt-0.5 text-xs text-muted-foreground">{t("login.getStartedHint")}</p>
<div className="flex flex-col gap-2"> </div>
<Button <div className="flex flex-col gap-2.5">
variant="outline" <GetStartedLink
className="h-10 w-full justify-start gap-2 rounded-xl" href="/register/organisation"
render={<Link href="/register/organisation" />} icon={Building2}
> title={t("login.registerOrg")}
<Building2 className="size-4" /> hint={t("login.registerOrgHint")}
{t("login.registerOrg")} primary
</Button> />
<Button <GetStartedLink
variant="outline" href="/register/request"
className="h-10 w-full justify-start gap-2 rounded-xl" icon={Handshake}
render={<Link href="/register/request" />} title={t("login.requestRegister")}
> hint={t("login.requestRegisterHint")}
<Handshake className="size-4" /> />
{t("login.requestRegister")} <GetStartedLink
</Button> href="/join"
<Button icon={KeyRound}
variant="outline" title={t("login.joinWithCode")}
className="h-10 w-full justify-start gap-2 rounded-xl" hint={t("login.joinWithCodeHint")}
render={<Link href="/join" />} />
>
<KeyRound className="size-4" />
{t("login.joinWithCode")}
</Button>
</div> </div>
</div> </div>

View File

@ -12,6 +12,7 @@ import { UserAvatar } from "@/components/shared/user-avatar";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
@ -51,7 +52,7 @@ export function AppTopbar({ onMenuClick }: { onMenuClick?: () => void }) {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
render={ render={
<Button variant="ghost" className="h-9 gap-2 px-2"> <Button variant="ghost" className="h-9 gap-2 px-2" nativeButton={false}>
<UserAvatar <UserAvatar
name={user.name} name={user.name}
avatarUrl={user.avatarUrl} avatarUrl={user.avatarUrl}
@ -70,14 +71,15 @@ export function AppTopbar({ onMenuClick }: { onMenuClick?: () => void }) {
} }
/> />
<DropdownMenuContent align="end" className="w-56"> <DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="space-y-1"> <DropdownMenuLabel className="font-normal space-y-1">
<div>{user.name}</div> <div className="text-sm font-medium text-foreground">{user.name}</div>
<div className="text-xs font-normal text-muted-foreground">{user.email}</div> <div className="text-xs font-normal text-muted-foreground">{user.email}</div>
<Badge variant="secondary" className="mt-1"> <Badge variant="secondary" className="mt-1">
{t(ROLE_META[user.activeRole].labelKey)} {t(ROLE_META[user.activeRole].labelKey)}
</Badge> </Badge>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => router.push("/profile")}> <DropdownMenuItem onClick={() => router.push("/profile")}>
{t("nav.profile")} {t("nav.profile")}
</DropdownMenuItem> </DropdownMenuItem>
@ -97,6 +99,7 @@ export function AppTopbar({ onMenuClick }: { onMenuClick?: () => void }) {
> >
{t("common.signOut")} {t("common.signOut")}
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>

View File

@ -44,12 +44,17 @@ function Button({
className, className,
variant = "default", variant = "default",
size = "default", size = "default",
nativeButton,
render,
...props ...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) { }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return ( return (
<ButtonPrimitive <ButtonPrimitive
data-slot="button" data-slot="button"
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
// Link (and other non-<button>) render props must opt out of native button semantics
nativeButton={nativeButton ?? render == null}
render={render}
{...props} {...props}
/> />
) )

View File

@ -61,6 +61,7 @@ function DropdownMenuLabel({
inset?: boolean inset?: boolean
}) { }) {
return ( return (
<MenuPrimitive.Group data-slot="dropdown-menu-label-group">
<MenuPrimitive.GroupLabel <MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label" data-slot="dropdown-menu-label"
data-inset={inset} data-inset={inset}
@ -70,6 +71,7 @@ function DropdownMenuLabel({
)} )}
{...props} {...props}
/> />
</MenuPrimitive.Group>
) )
} }

View File

@ -187,15 +187,30 @@
"login": { "login": {
"eyebrow": "የተቋም ፖርታል", "eyebrow": "የተቋም ፖርታል",
"title": "እንኳን ደህና መጡ", "title": "እንኳን ደህና መጡ",
"subtitle": "በሚና የፖርታሉን ለመሞከር የማሳያ ተጠቃሚ ይምረጡ።", "subtitle": "በGoogle፣ ኢሜይል፣ ስልክ ወይም ቴሌግራም ይግቡ — ወይም በማሳያ ተጠቃሚ ይሞክሩ።",
"brandBlurb": "ለድርጅቶች የኮርፖሬት የህክምና ጥቅማጥቅሞች — አባላት፣ ፓኬጆች፣ ፋይናንስ እና እንክብካቤ በአንድ ቦታ።", "brandBlurb": "ለድርጅቶች የኮርፖሬት የህክምና ጥቅማጥቅሞች — አባላት፣ ፓኬጆች፣ ፋይናንስ እና እንክብካቤ በአንድ ቦታ።",
"brandHeadline": "ከሰራተኞችዎ ጋር የሚያድጉ ጥቅማጥቅሞች",
"sideFeaturePeople": "አባላትን እና ጥገኞችን በደቂቃዎች ውስጥ ያስመዝግቡ",
"sideFeaturePackages": "HR የሚያስተዳድራቸው ፓኬጆች እና አበል",
"sideFeatureFinance": "ወጪ እና መግለጫዎች ያለ ክሊኒካል ዝርዝር",
"signInWith": "በሚከተለው ይግቡ",
"google": "Google",
"email": "ኢሜይል",
"phone": "ስልክ",
"telegram": "ቴሌግራም",
"orDemo": "ወይም በማሳያ ተጠቃሚ ይቀጥሉ",
"demoMethodNote": "የ{method} መግቢያ በዚህ ማሳያ ተሳልሏል። እንደ የተመረጠው የሙከራ ተጠቃሚ ይቀጥላል።",
"selectUser": "የሙከራ ተጠቃሚ", "selectUser": "የሙከራ ተጠቃሚ",
"selectUserPlaceholder": "የማሳያ ተጠቃሚ ይምረጡ", "selectUserPlaceholder": "የማሳያ ተጠቃሚ ይምረጡ",
"continueAs": "እንደ {role} ቀጥል", "continueAs": "እንደ {role} ቀጥል",
"getStarted": "ጀምር", "getStarted": "አዲስ ነዎት ወደ Gishen?",
"getStartedHint": "ድርጅትዎን ያዘጋጁ ወይም ካለው ጋር ይቀላቀሉ።",
"registerOrg": "ድርጅት ይመዝገቡ", "registerOrg": "ድርጅት ይመዝገቡ",
"registerOrgHint": "የተቋም መለያዎን ይፍጠሩ",
"requestRegister": "ምዝገባ ይጠይቁ", "requestRegister": "ምዝገባ ይጠይቁ",
"requestRegisterHint": "Gishen ቡድንዎን እንዲያስገባ ይጠይቁ",
"joinWithCode": "በኮድ ይቀላቀሉ", "joinWithCode": "በኮድ ይቀላቀሉ",
"joinWithCodeHint": "የድርጅት መቀላቀያ ኮድዎን ይጠቀሙ",
"personas": { "personas": {
"superUser": "ሱፐር ተጠቃሚ", "superUser": "ሱፐር ተጠቃሚ",
"superUserDesc": "ሙሉ መዳረሻ — HR፣ ፋይናንስ፣ ፓኬጆች እና ክሊኒክ። ብዙ ሚና መቀያየር ይቻላል።", "superUserDesc": "ሙሉ መዳረሻ — HR፣ ፋይናንስ፣ ፓኬጆች እና ክሊኒክ። ብዙ ሚና መቀያየር ይቻላል።",

View File

@ -187,15 +187,30 @@
"login": { "login": {
"eyebrow": "Institutional portal", "eyebrow": "Institutional portal",
"title": "Welcome back", "title": "Welcome back",
"subtitle": "Choose a demo persona to explore the portal by role.", "subtitle": "Sign in with Google, email, phone, or Telegram — or explore with a demo persona.",
"brandBlurb": "Corporate medical benefits for organisations — members, packages, finance, and care in one place.", "brandBlurb": "Corporate medical benefits for organisations — members, packages, finance, and care in one place.",
"brandHeadline": "Benefits that scale with your workforce",
"sideFeaturePeople": "Enrol members and dependants in minutes",
"sideFeaturePackages": "Packages and allowances HR can actually manage",
"sideFeatureFinance": "Spend and statements without clinical noise",
"signInWith": "Sign in with",
"google": "Google",
"email": "Email",
"phone": "Phone",
"telegram": "Telegram",
"orDemo": "Or continue with a demo persona",
"demoMethodNote": "{method} sign-in is mocked in this demo. Continuing as the selected test user.",
"selectUser": "Test user", "selectUser": "Test user",
"selectUserPlaceholder": "Select a demo user", "selectUserPlaceholder": "Select a demo user",
"continueAs": "Continue as {role}", "continueAs": "Continue as {role}",
"getStarted": "Get started", "getStarted": "New to Gishen?",
"getStartedHint": "Set up your organisation or join an existing one.",
"registerOrg": "Register organisation", "registerOrg": "Register organisation",
"registerOrgHint": "Create your institutional account",
"requestRegister": "Request registration", "requestRegister": "Request registration",
"requestRegisterHint": "Ask Gishen to onboard your team",
"joinWithCode": "Join with code", "joinWithCode": "Join with code",
"joinWithCodeHint": "Use your organisation join code",
"personas": { "personas": {
"superUser": "Super User", "superUser": "Super User",
"superUserDesc": "Full access across HR, finance, packages, and clinical support views. Multi-role switcher enabled.", "superUserDesc": "Full access across HR, finance, packages, and clinical support views. Multi-role switcher enabled.",