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:
parent
8fe6d58a09
commit
f2e992e0bd
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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፣ ፋይናንስ፣ ፓኬጆች እና ክሊኒክ። ብዙ ሚና መቀያየር ይቻላል።",
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
|
|
||||||
Reference in New Issue
Block a user