14 Commits

Author SHA1 Message Date
tanweer919 948abf75fd feat(auth): first-time Google onboarding (option 3)
After a Google sign-in with no CRM profile, route to /portal/onboarding — the
registration flow in mode='onboard': email skipped (Google-verified, prefilled
via getUserEmail), sets a password (updateUser) so email+password login also
works, skips the OTP-verify step, and persists the profile via crm.account.register.
Existing-profile users go straight to the dashboard. Bumps SDK ^0.2.3.
2026-07-13 03:35:55 +05:30
tanweer919 34cb44cfa8 feat(auth): gate Google sign-in on an existing CRM profile
Google is sign-IN only: after OAuth, check crm.account.registrationStatus; if the
account has no CRM profile, sign back out and show an error on the login screen
instead of admitting a profile-less user. (Onboarding to collect the profile comes later.)
2026-07-13 03:22:04 +05:30
tanweer919 f38fa80bde polish(dashboard): show real ACE role in user chip instead of static 'Property Owner' 2026-07-13 03:03:17 +05:30
tanweer919 86af77c92b feat(dashboard): real user in sidebar + sign-out; strip demo/mock hints
- Sidebar footer now shows the ACE identity (was static 'James Carter') with a
  Sign-out menu; topbar user gets a Sign-out dropdown too. Both call useAuth().logout.
- Remove all visible 'Demo:' hints from login + register and the 'Demo mode' subtitle.
2026-07-13 02:57:51 +05:30
tanweer919 27e7bdf8f5 fix(auth): bump appshell-sdk 0.2.2 (ready-after-restore) — session persists on reload 2026-07-13 02:41:39 +05:30
tanweer919 64be4b9b3f fix(auth): don't redirect from dashboard until SDK boot completes
AuthGate redirected to /portal/login whenever status !== authenticated, which
during boot (before restore() resolves) dropped a valid session on reload. Gate
the redirect on useAppShell().ready so we only bounce once auth has resolved.
2026-07-13 02:35:13 +05:30
tanweer919 5186497c50 chore(auth): remove debug logging from OAuth + registration error paths 2026-07-13 02:17:22 +05:30
tanweer919 0e8a207640 fix(auth): bump appshell-sdk 0.2.1 (PKCE verifier fix); tidy OAuth error handling 2026-07-13 02:02:24 +05:30
tanweer919 7570fe7be3 fix(auth): surface the real OAuth completion error (console + flash) 2026-07-13 01:45:10 +05:30
tanweer919 6e1a895e7e fix(auth): complete Google OAuth after SDK boot (was stuck on ?code=…#identify)
The OAuth-return effect ran on mount before AppShell finished booting, so
completeOAuthLogin no-oped (sdk.auth undefined → null) and fell back to identify,
never retrying. Gate it on useAppShell().ready so it runs once auth is up, and
strip ?code= from the URL on success.
2026-07-13 01:31:37 +05:30
tanweer919 74caae0710 feat(auth): Google-only social; sign-up email/password only; phone login entry
- SocialButtons: only Google (Microsoft/Apple hidden).
- Sign-up: remove all social (email/password only).
- Login: keep Google + email/password (single step) + email/SMS OTP; add a direct
  'Sign in with a phone number' entry (SMS OTP), and rename the OTP link to
  'Sign in with a one-time code'. All Supabase-supported; no mandatory 2FA.
2026-07-11 20:48:47 +05:30
tanweer919 339e2b007a feat(register): persist full registration to crm.account.register
Lift StepAddress/AddressBlock state up so finish() can send the complete
sign-up payload (name, phone, persona, allottee id/names, registered+mailing
address, consent) to be-crm crm.account.register after auth. Config-gated;
non-fatal if the domain persist fails (auth account already created).
2026-07-11 06:19:49 +05:30
tanweer919 565019a75c feat(team+auth): live multi-role team management + email/SMS OTP login
- team-management: new useTeamData() data layer serves live be-crm data door
  (crm.team.member.search/role.list/invitation.list + set-roles/perm/invite
  commands) or the mock fallback, gated on isShellConfigured(). A member can
  hold MANY roles: role chips + multi-select picker in Edit/Invite modals.
- login OTP: OtpVerify wired to real Supabase email + SMS OTP (passwordless)
  via appshell sendEmailOtp/verifyEmailOtp + sendPhoneOtp/verifyPhoneOtp.
- bump @abe-kap/appshell-sdk ^0.2.0 (phone OTP).
2026-07-11 04:44:54 +05:30
tanweer919 1d37593102 feat(appshell): integrate @abe-kap/appshell-sdk (strangler, mock fallback)
Wire the AppShell SDK for auth/identity across the CRM, gated behind
isShellConfigured() so the existing mock portal + static demo user stay
the fallback until the Shell BFF is configured.

- providers.tsx: app-wide <AppShellProvider> (auth passed only when Supabase env set)
- next.config.ts: transpilePackages + /shell -> BFF rewrite (keeps /api/geo intact)
- login: password -> login(), social -> loginWithOAuth(), OAuth return -> completeOAuthLogin()
- register: email/password sign-ups call register() on finish
- dashboard: AuthGate bounces unauthenticated visitors; topbar shows ACE identity
- docs/APPSHELL_INTEGRATION.md, .env.local.example, .npmrc for GitHub Packages
2026-07-09 20:51:17 +05:30
19 changed files with 947 additions and 292 deletions
+16
View File
@@ -0,0 +1,16 @@
# appshell-sdk / Shell BFF config. Leave unset to keep the existing mock portal
# (the app still runs). Set these once the Shell BFF is deployed for real auth.
# Browser-safe Supabase config (the anon key is PUBLISHABLE — never service_role):
NEXT_PUBLIC_SUPABASE_URL=https://<project-ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=<publishable-anon-key>
# Where the browser reaches the Shell BFF (same-origin path; rewritten to BFF_ORIGIN).
NEXT_PUBLIC_BFF_BASE_URL=/shell
# The deployed Shell BFF origin — the /shell/* rewrite proxies here (see next.config.ts).
BFF_ORIGIN=http://localhost:4000
# Installing @abe-kap/appshell-sdk (GitHub Packages) needs a read:packages token:
# locally: export NODE_AUTH_TOKEN=<token> before npm install
# Vercel: set NODE_AUTH_TOKEN as a project env var
+2
View File
@@ -0,0 +1,2 @@
@abe-kap:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
+57
View File
@@ -0,0 +1,57 @@
# AppShell integration (lynkeduppro-crm)
This app now consumes **`@abe-kap/appshell-sdk`** for authentication, identity, and
(eventually) the data door. The integration follows a **strangler pattern**: the SDK
is wired in everywhere, but every real call is gated behind `isShellConfigured()`. When
the Shell isn't configured (no `NEXT_PUBLIC_SUPABASE_URL`), the app behaves exactly as
before — the existing mock portal and static demo user remain the fallback, so the
deployed demo keeps working until the Shell BFF is live.
## What flips it on
Set these (see `.env.local.example`) and redeploy:
| Var | Purpose |
| --- | --- |
| `NEXT_PUBLIC_SUPABASE_URL` | Presence flips the app from mock → real auth (the `isShellConfigured()` switch). |
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Browser-safe publishable anon key. |
| `NEXT_PUBLIC_BFF_BASE_URL` | Where the browser reaches the BFF (default `/shell`). |
| `BFF_ORIGIN` | Deployed Shell BFF origin; the `/shell/*` rewrite proxies here. |
| `NODE_AUTH_TOKEN` | `read:packages` token to install the SDK from GitHub Packages (local + Vercel). |
## How it's wired
- **`src/app/providers.tsx`** — mounts `<AppShellProvider>` app-wide (via the root
layout). `auth` is passed **only when Supabase is configured**; otherwise the provider
mounts without auth and `useAuth()` reports `unauthenticated` (never crashes).
- **`next.config.ts`** — `transpilePackages` for the SDK + a `/shell/* → ${BFF_ORIGIN}/api/*`
rewrite so the HttpOnly session cookie flows same-origin. We use `/shell` (not `/api`)
to avoid clobbering the existing `/api/geo` route.
- **Login** (`components/portal/login-flow.tsx`) — password sign-in → `login()`,
social buttons → `loginWithOAuth()` (google/microsoft→azure/apple), and the OAuth
return is finished with `completeOAuthLogin()`. A resolved ACE goes straight to
`/dashboard` (Supabase handles MFA server-side; no mock 2-step).
- **Register** (`components/portal/register-flow.tsx`) — email/password sign-ups call
`register()` on finish. (SSO sign-ups already hold a session from OAuth.)
- **Dashboard gate** (`components/dashboard/auth-gate.tsx`) — bounces unauthenticated
visitors to the portal; pass-through when the Shell is off.
- **Topbar** (`components/dashboard/topbar.tsx`) — shows the real identity from the
App Context Envelope (`useAuth().user`), falling back to the static demo user.
## Local build note
`next build` needs Node ≥ 20.9. Installing the SDK needs a GitHub Packages token; for a
token-free local build you can symlink the workspace copy:
```
mkdir -p node_modules/@abe-kap
ln -s /abs/path/to/appshell-sdk node_modules/@abe-kap/appshell-sdk
```
## Not yet wired (follow-ups)
- Register-via-OAuth still uses the mock `startSocial` (login OAuth is real).
- Email/phone OTP steps in register are still mock (SDK has `sendEmailOtp`/`verifyEmailOtp`).
- Passkey enrollment, tenant switcher, logout menu, and the data door (`useQuery`/
`command`) are available in the SDK but not surfaced in the UI yet.
- Backend integration (frontend ↔ be-crm through the data door) is deliberately deferred.
+9 -1
View File
@@ -1,7 +1,15 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ // The SDK ships ESM/TS; let Next transpile it.
transpilePackages: ["@abe-kap/appshell-sdk"],
// The browser calls the Shell BFF same-origin under /shell (so the HttpOnly
// session cookie flows). We deliberately use /shell (NOT /api) to avoid
// clobbering the existing /api/geo route. Point BFF_ORIGIN at the deployed BFF.
async rewrites() {
const bff = process.env.BFF_ORIGIN ?? "http://localhost:4000";
return [{ source: "/shell/:path*", destination: `${bff}/api/:path*` }];
},
}; };
export default nextConfig; export default nextConfig;
+1
View File
@@ -9,6 +9,7 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@abe-kap/appshell-sdk": "^0.2.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.21.0", "lucide-react": "^1.21.0",
"next": "16.2.9", "next": "16.2.9",
+14
View File
@@ -106,6 +106,11 @@
.sb-user .av { width: 30px; height: 30px; border-radius: 8px; object-fit: cover; flex: 0 0 30px; } .sb-user .av { width: 30px; height: 30px; border-radius: 8px; object-fit: cover; flex: 0 0 30px; }
.sb-user .nm { font-size: 12.5px; font-weight: 600; } .sb-user .nm { font-size: 12.5px; font-weight: 600; }
.sb-user .rl { font-size: 11px; color: var(--faint); } .sb-user .rl { font-size: 11px; color: var(--faint); }
.sb-user .nm, .sb-user .rl { max-width: 130px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sb-user-menu { position: absolute; bottom: 56px; left: 12px; right: 12px; z-index: 30; padding: 6px; border-radius: 12px; border: 1px solid var(--border-2); background: var(--panel); box-shadow: var(--shadow), 0 8px 30px -12px rgba(0,0,0,0.55); animation: ds-rise 0.14s ease; }
.sb-user-menu button { display: flex; align-items: center; gap: 9px; width: 100%; padding: 9px 10px; border: 0; border-radius: 9px; background: none; color: var(--text-2); font-family: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; text-align: left; }
.sb-user-menu button:hover { background: var(--panel-2); }
.sb-user-menu button.danger { color: var(--red, #ef4444); }
/* ---- main ---- */ /* ---- main ---- */
.dash-main { flex: 1; min-width: 0; display: flex; flex-direction: column; } .dash-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
@@ -963,6 +968,15 @@
.dash-root .tm-roleselect .ds-select { border: 0; background-color: transparent; height: 34px; padding: 0 26px 0 0; font-weight: 600; font-size: 12.5px; box-shadow: none; background-position: calc(100% - 6px) 15px, calc(100% - 1px) 15px; } .dash-root .tm-roleselect .ds-select { border: 0; background-color: transparent; height: 34px; padding: 0 26px 0 0; font-weight: 600; font-size: 12.5px; box-shadow: none; background-position: calc(100% - 6px) 15px, calc(100% - 1px) 15px; }
.dash-root .tm-roleselect .ds-select:focus { box-shadow: none; } .dash-root .tm-roleselect .ds-select:focus { box-shadow: none; }
/* Multi-role: chips (read-only) + picker (toggle) */
.dash-root .tm-rolechips { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.dash-root .tm-rolepicker { display: flex; flex-wrap: wrap; gap: 8px; }
.dash-root .tm-rolepick { --rc: var(--text-2); display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 12px 0 9px; border-radius: 99px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--text-1); font-size: 12.5px; font-weight: 600; cursor: pointer; transition: 0.14s; }
.dash-root .tm-rolepick:hover { border-color: color-mix(in srgb, var(--rc) 55%, transparent); }
.dash-root .tm-rolepick.on { color: var(--rc); background: color-mix(in srgb, var(--rc) 12%, transparent); border-color: color-mix(in srgb, var(--rc) 40%, transparent); }
.dash-root .tm-rolepick-check { display: grid; place-items: center; width: 16px; height: 16px; border-radius: 5px; border: 1.5px solid var(--border-2); color: #fff; flex: 0 0 auto; }
.dash-root .tm-rolepick.on .tm-rolepick-check { background: var(--rc); border-color: var(--rc); }
.dash-root .tm-statuscell { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; } .dash-root .tm-statuscell { display: flex; align-items: center; gap: 7px; font-size: 12.5px; font-weight: 600; }
.dash-root .tm-lastactive { color: var(--faint); font-weight: 500; font-size: 11.5px; margin-left: 2px; } .dash-root .tm-lastactive { color: var(--faint); font-weight: 500; font-size: 11.5px; margin-left: 2px; }
+6 -1
View File
@@ -1,5 +1,10 @@
import { Dashboard } from "@/components/dashboard/dashboard"; import { Dashboard } from "@/components/dashboard/dashboard";
import { AuthGate } from "@/components/dashboard/auth-gate";
export default function DashboardPage() { export default function DashboardPage() {
return <Dashboard />; return (
<AuthGate>
<Dashboard />
</AuthGate>
);
} }
+4 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { siteConfig } from "@/config/site"; import { siteConfig } from "@/config/site";
import { Providers } from "./providers";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const geistSans = Geist({
@@ -31,7 +32,9 @@ export default function RootLayout({
lang="en" lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
> >
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">
<Providers>{children}</Providers>
</body>
</html> </html>
); );
} }
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { PortalAside, PanelBrand } from "@/components/portal/parts";
import { RegisterFlow } from "@/components/portal/register-flow";
import { CookieBanner } from "@/components/portal/bits";
import { isShellConfigured } from "@/lib/appshell";
/**
* Post-OAuth onboarding a first-time Google user completes their CRM profile
* (everything except email, which Google already verified). Only reachable while
* authenticated; unauthenticated visitors are sent back to sign in.
*/
export default function OnboardingPage() {
const router = useRouter();
const { status } = useAuth();
const { ready } = useAppShell();
useEffect(() => {
if (isShellConfigured() && ready && status === "unauthenticated") router.replace("/portal/login");
}, [ready, status, router]);
return (
<main className="portal-main">
<span className="portal-grid" />
<div className="portal-split anim-in">
<PortalAside />
<section className="portal-panel">
<div style={{ width: "100%", maxWidth: 420 }}>
<PanelBrand />
<RegisterFlow mode="onboard" />
</div>
</section>
</div>
<CookieBanner />
</main>
);
}
+29
View File
@@ -0,0 +1,29 @@
"use client";
// AppShell runs in the browser (boot, React context), so the provider is a
// Client Component. When Supabase env is NOT configured (no BFF yet) we mount the
// provider WITHOUT `auth` — `useAuth()` still works (returns nulls / status
// "unauthenticated"), and the existing mock portal remains the fallback. Once the
// env is set + the Shell BFF is deployed, real auth flows through appshell-sdk.
import { AppShellProvider } from "@abe-kap/appshell-sdk/react";
import type { AppShellOptions } from "@abe-kap/appshell-sdk";
import type { ReactNode } from "react";
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const bffBaseUrl = process.env.NEXT_PUBLIC_BFF_BASE_URL ?? "/shell";
const options: AppShellOptions = supabaseUrl
? {
auth: {
supabaseUrl,
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "",
appId: "crm-web",
},
bffBaseUrl,
}
: { bffBaseUrl };
export function Providers({ children }: { children: ReactNode }) {
return <AppShellProvider options={options}>{children}</AppShellProvider>;
}
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "@/lib/appshell";
/**
* Client gate for the dashboard. When the Shell is live, an unauthenticated
* visitor is bounced to the portal; while the session is resolving we hold a
* lightweight splash. When the Shell is NOT configured this is a pass-through,
* so the existing mock demo (any login /dashboard) keeps working unchanged.
*/
export function AuthGate({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { status } = useAuth();
const { ready } = useAppShell();
const shell = isShellConfigured();
useEffect(() => {
// Only bounce to login once the SDK has finished booting AND restore() has
// resolved to unauthenticated. Redirecting while boot is still in flight would
// drop a perfectly valid session on reload (the status is transiently not-yet
// "authenticated" during boot).
if (shell && ready && status === "unauthenticated") router.replace("/portal/login");
}, [shell, ready, status, router]);
if (shell && (!ready || status !== "authenticated")) {
return (
<div style={{ minHeight: "60vh", display: "grid", placeItems: "center", color: "var(--muted, #888)" }}>
{ready && status === "unauthenticated" ? "Redirecting to sign in…" : "Loading your workspace…"}
</div>
);
}
return <>{children}</>;
}
+38 -7
View File
@@ -1,9 +1,16 @@
"use client"; "use client";
import { ChevronsUpDown } from "lucide-react"; import { useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronsUpDown, LogOut } from "lucide-react";
import { useAuth } from "@abe-kap/appshell-sdk/react";
import { Icon } from "./ui"; import { Icon } from "./ui";
import { user } from "./account-data"; import { user } from "./account-data";
function initialsOf(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
}
export type NavItem = { key: string; label: string; icon: string; subtitle?: string }; export type NavItem = { key: string; label: string; icon: string; subtitle?: string };
export type NavGroup = { title: string; items: NavItem[] }; export type NavGroup = { title: string; items: NavItem[] };
@@ -61,6 +68,22 @@ export const NAV_GROUPS: NavGroup[] = [
export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items); export const NAV_ITEMS: NavItem[] = NAV_GROUPS.flatMap((g) => g.items);
export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: string) => void }) { export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: string) => void }) {
const router = useRouter();
const { user: me, logout, context } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
// Real signed-in identity from the App Context Envelope; fall back to the static
// demo user only when the Shell isn't wired.
const roleLabel = context?.scope?.role ? context.scope.role.charAt(0).toUpperCase() + context.scope.role.slice(1) : "";
const name = me?.displayName || user.name;
const initials = me ? initialsOf(me.displayName) : user.initials;
const secondary = me?.email || roleLabel || user.role;
async function signOut() {
setMenuOpen(false);
try { await logout(); } catch { /* ignore — proceed to portal either way */ }
router.replace("/portal/login");
}
return ( return (
<aside className="dash-sidebar"> <aside className="dash-sidebar">
<div className="dash-brand"> <div className="dash-brand">
@@ -82,12 +105,20 @@ export function Sidebar({ active, onSelect }: { active: string; onSelect: (k: st
))} ))}
</nav> </nav>
<div className="sb-foot"> <div className="sb-foot" style={{ position: "relative" }}>
<button className="sb-user"> {menuOpen && (
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{user.initials}</span> <>
<div style={{ flex: 1, textAlign: "left" }}> <div className="tm-menu-scrim" onClick={() => setMenuOpen(false)} />
<div className="nm">{user.name}</div> <div className="sb-user-menu" role="menu">
<div className="rl">{user.role}</div> <button className="danger" onClick={signOut}><LogOut size={15} /> Sign out</button>
</div>
</>
)}
<button className="sb-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
<div style={{ flex: 1, textAlign: "left", minWidth: 0 }}>
<div className="nm">{name}</div>
<div className="rl" style={{ overflow: "hidden", textOverflow: "ellipsis" }}>{secondary}</div>
</div> </div>
<ChevronsUpDown size={15} /> <ChevronsUpDown size={15} />
</button> </button>
+183 -197
View File
@@ -5,43 +5,44 @@
// a creative "crew" experience. // a creative "crew" experience.
// · Crew hero : avatar stack, live presence, headline stats // · Crew hero : avatar stack, live presence, headline stats
// · Members : people gallery (cards) ⇄ compact list, with // · Members : people gallery (cards) ⇄ compact list, with
// role filter chips, search, inline role swap, // role filter chips, search, MULTI-role chips,
// deal-load meters and row actions // deal-load meters and row actions
// · Roles : permission-coverage rings + live toggle matrix // · Roles : permission-coverage rings + live toggle matrix
// · Invites : envelope timeline with resend / revoke // · Invites : envelope timeline with resend / revoke
// Everything is local state so the screen is fully clickable. // Data comes from useTeamData(): the local mock when the Shell
// isn't configured, or the live be-crm data door (crm.team.*)
// when it is. A member can hold MANY roles.
// ============================================================ // ============================================================
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
Avatar, Btn, Field, Icon, Modal, Pill, StatusDot, Toggle, useToast, Avatar, Btn, Field, Icon, Modal, Pill, StatusDot, Toggle, useToast,
} from "./ui"; } from "./ui";
import { import { permissionGroups, allPermissionIds } from "./team-data";
members as seedMembers, roles as seedRoles, pendingInvites as seedInvites, import { useTeamData, type UiMember, type UiRole, type UiStatus } from "@/lib/team-api";
permissionGroups, allPermissionIds, type Member, type Role, type Invite,
} from "./team-data";
const STATUS_LABEL: Record<Member["status"], string> = { active: "Active", away: "Away", offline: "Offline" }; const STATUS_LABEL: Record<UiStatus, string> = { active: "Active", away: "Away", offline: "Offline" };
const toDot = (s: Member["status"]) => (s === "offline" ? "offline" : s === "away" ? "away" : "online"); const toDot = (s: UiStatus) => (s === "offline" ? "offline" : s === "away" ? "away" : "online");
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
export function TeamManagement() { export function TeamManagement() {
const toast = useToast(); const toast = useToast();
const team = useTeamData();
const { members, roles, invites, live } = team;
const [tab, setTab] = useState("members"); const [tab, setTab] = useState("members");
const [view, setView] = useState<"grid" | "list">("grid"); const [view, setView] = useState<"grid" | "list">("grid");
const [members, setMembers] = useState<Member[]>(seedMembers);
const [roles, setRoles] = useState<Role[]>(seedRoles);
const [invites, setInvites] = useState<Invite[]>(seedInvites);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [roleFilter, setRoleFilter] = useState("all"); const [roleFilter, setRoleFilter] = useState("all");
const [inviteOpen, setInviteOpen] = useState(false); const [inviteOpen, setInviteOpen] = useState(false);
const [editing, setEditing] = useState<Member | null>(null); const [editing, setEditing] = useState<UiMember | null>(null);
const [confirmRemove, setConfirmRemove] = useState<Member | null>(null); const [confirmRemove, setConfirmRemove] = useState<UiMember | null>(null);
const roleById = useMemo(() => Object.fromEntries(roles.map((r) => [r.id, r])), [roles]); const roleById = useMemo(() => Object.fromEntries(roles.map((r) => [r.id, r])), [roles]);
const assignableRoles = useMemo(() => roles.filter((r) => !r.isOwner), [roles]);
const countByRole = useMemo(() => { const countByRole = useMemo(() => {
const m: Record<string, number> = {}; const m: Record<string, number> = {};
for (const mem of members) m[mem.roleId] = (m[mem.roleId] ?? 0) + 1; for (const mem of members) for (const rid of mem.roleIds) m[rid] = (m[rid] ?? 0) + 1;
return m; return m;
}, [members]); }, [members]);
const maxDeals = useMemo(() => Math.max(1, ...members.map((m) => m.deals)), [members]); const maxDeals = useMemo(() => Math.max(1, ...members.map((m) => m.deals)), [members]);
@@ -49,31 +50,29 @@ export function TeamManagement() {
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
return members.filter((m) => { return members.filter((m) => {
const matchRole = roleFilter === "all" || m.roleId === roleFilter; const matchRole = roleFilter === "all" || m.roleIds.includes(roleFilter);
const matchQ = !q || m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q) || m.title.toLowerCase().includes(q); const matchQ = !q || m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q) || m.title.toLowerCase().includes(q);
return matchRole && matchQ; return matchRole && matchQ;
}); });
}, [members, query, roleFilter]); }, [members, query, roleFilter]);
const activeCount = members.filter((m) => m.status === "active").length; const activeCount = members.filter((m) => m.status === "active").length;
const totalDeals = members.reduce((sum, m) => sum + m.deals, 0);
function changeRole(id: string, roleId: string) { /* ---- mutations (async; live → data door, mock → local) ---- */
setMembers((list) => list.map((m) => (m.id === id ? { ...m, roleId } : m))); async function saveRoles(m: UiMember, roleIds: string[]) {
const m = members.find((x) => x.id === id); try {
toast.push({ tone: "success", title: "Role updated", desc: `${m?.name ?? "Member"} is now ${roleById[roleId]?.name ?? roleId}.` }); await team.setMemberRoles(m.id, roleIds);
toast.push({ tone: "success", title: "Roles updated", desc: `${m.name} now holds ${roleIds.length} role${roleIds.length === 1 ? "" : "s"}.` });
} catch (e) { toast.push({ tone: "error", title: "Couldn't update roles", desc: (e as Error).message }); }
} }
function removeMember(m: Member) { async function removeMember(m: UiMember) {
setMembers((list) => list.filter((x) => x.id !== m.id));
setConfirmRemove(null); setConfirmRemove(null);
toast.push({ tone: "info", title: "Member removed", desc: `${m.name} no longer has access.` }); try { await team.removeMember(m.id); toast.push({ tone: "info", title: "Member removed", desc: `${m.name} no longer has access.` }); }
catch (e) { toast.push({ tone: "error", title: "Couldn't remove member", desc: (e as Error).message }); }
} }
function togglePerm(roleId: string, permId: string) { async function togglePerm(roleId: string, permId: string, granted: boolean) {
setRoles((list) => list.map((r) => { try { await team.setPermission(roleId, permId, granted); }
if (r.id !== roleId) return r; catch (e) { toast.push({ tone: "error", title: "Couldn't update permission", desc: (e as Error).message }); }
const has = r.perms.includes(permId);
return { ...r, perms: has ? r.perms.filter((p) => p !== permId) : [...r.perms, permId] };
}));
} }
// Crew stack — show online folks first, capped to 6 with a +N bubble. // Crew stack — show online folks first, capped to 6 with a +N bubble.
@@ -97,7 +96,10 @@ export function TeamManagement() {
{stackRest > 0 && <span className="tm-stack-more">+{stackRest}</span>} {stackRest > 0 && <span className="tm-stack-more">+{stackRest}</span>}
</div> </div>
<div className="tm-hero-copy"> <div className="tm-hero-copy">
<div className="tm-hero-eyebrow"><Icon name="team" size={13} /> Your crew · LynkedUp Pro</div> <div className="tm-hero-eyebrow">
<Icon name="team" size={13} /> Your crew · LynkedUp Pro
{live && <Pill tone="green" style={{ marginLeft: 8 }}>Live</Pill>}
</div>
<h1>Team Management</h1> <h1>Team Management</h1>
<p>Manage your crew, assign roles and control exactly what each person can do.</p> <p>Manage your crew, assign roles and control exactly what each person can do.</p>
<div className="tm-hero-live"> <div className="tm-hero-live">
@@ -112,14 +114,6 @@ export function TeamManagement() {
</div> </div>
</header> </header>
{/* ---- Metric strip ------------------------------------- */}
{/* <div className="tm-stats">
<StatCard icon="people" tone="var(--orange)" value={members.length} label="Total members" foot="across the workspace" />
<StatCard icon="check-circle" tone="var(--green)" value={activeCount} label="Active now" foot={`${Math.round((activeCount / members.length) * 100)}% of the crew online`} bar={activeCount / members.length} />
<StatCard icon="shield" tone="var(--purple)" value={roles.length} label="Roles defined" foot={`${roles.filter((r) => !r.system).length} custom`} />
<StatCard icon="pipeline" tone="var(--blue)" value={totalDeals} label="Open deals owned" foot="by the whole team" />
</div> */}
{/* ---- Tabs --------------------------------------------- */} {/* ---- Tabs --------------------------------------------- */}
<div className="tm-tabs" role="tablist"> <div className="tm-tabs" role="tablist">
{[ {[
@@ -134,6 +128,8 @@ export function TeamManagement() {
))} ))}
</div> </div>
{team.error && <div className="card tm-empty" style={{ borderColor: "var(--red, #ef4444)" }}><p>Couldn&apos;t load team data: {team.error}</p></div>}
{/* ---- MEMBERS ------------------------------------------ */} {/* ---- MEMBERS ------------------------------------------ */}
{tab === "members" && ( {tab === "members" && (
<div className="view-body"> <div className="view-body">
@@ -161,88 +157,62 @@ export function TeamManagement() {
))} ))}
</div> </div>
{filtered.length === 0 ? ( {team.live && team.loading && members.length === 0 ? (
<div className="card tm-empty"><span className="tm-empty-ic"><Icon name="people" size={24} /></span><p>Loading your crew</p></div>
) : filtered.length === 0 ? (
<div className="card tm-empty"><span className="tm-empty-ic"><Icon name="search" size={24} /></span><p>No members match your search.</p><Btn variant="soft" size="sm" onClick={() => { setQuery(""); setRoleFilter("all"); }}>Clear filters</Btn></div> <div className="card tm-empty"><span className="tm-empty-ic"><Icon name="search" size={24} /></span><p>No members match your search.</p><Btn variant="soft" size="sm" onClick={() => { setQuery(""); setRoleFilter("all"); }}>Clear filters</Btn></div>
) : view === "grid" ? ( ) : view === "grid" ? (
<div className="tm-gallery"> <div className="tm-gallery">
{filtered.map((m) => { {filtered.map((m) => (
const role = roleById[m.roleId]; <article className="card tm-pcard" key={m.id} style={{ ["--rc" as string]: roleById[m.roleIds[0]]?.color }}>
const isOwner = m.roleId === "owner"; <span className="tm-pcard-bg" aria-hidden />
return ( <div className="tm-pcard-top">
<article className="card tm-pcard" key={m.id} style={{ ["--rc" as string]: role?.color }}> <span className="tm-pcard-av"><Avatar initials={m.initials} gradient={m.gradient} size={58} status={toDot(m.status)} /></span>
<span className="tm-pcard-bg" aria-hidden /> <RowMenu disabled={m.isOwner} onEdit={() => setEditing(m)} onRemove={() => setConfirmRemove(m)} />
<div className="tm-pcard-top"> </div>
<span className="tm-pcard-av"><Avatar initials={m.initials} gradient={m.gradient} size={58} status={toDot(m.status)} /></span> <div className="tm-pcard-name">{m.name}{m.isOwner && <span className="tm-crown" title="Account owner"><Icon name="star" size={13} /></span>}</div>
<RowMenu disabled={isOwner} onEdit={() => setEditing(m)} onRemove={() => setConfirmRemove(m)} /> <div className="tm-pcard-title">{m.title || <span className="tm-dash">No title</span>}</div>
</div> <div className="tm-pcard-email"><Icon name="mail" size={12} /> {m.email}</div>
<div className="tm-pcard-name">{m.name}{isOwner && <span className="tm-crown" title="Account owner"><Icon name="star" size={13} /></span>}</div>
<div className="tm-pcard-title">{m.title}</div>
<div className="tm-pcard-email"><Icon name="mail" size={12} /> {m.email}</div>
<div className="tm-pcard-role"> <div className="tm-pcard-role">
{isOwner ? ( <RoleChips roleIds={m.roleIds} roleById={roleById} />
<span className="tm-rolebadge" style={{ ["--rc" as string]: role?.color }}><Icon name="shield" size={13} /> {role?.name}</span> </div>
) : (
<div className="tm-roleselect" style={{ ["--rc" as string]: role?.color }}>
<span className="tm-roledot" />
<select className="ds-select" value={m.roleId} onChange={(e) => changeRole(m.id, e.target.value)} aria-label={`Role for ${m.name}`}>
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</div>
)}
</div>
<div className="tm-meter" title={`${m.deals} open deals`}> <div className="tm-meter" title={`${m.deals} open deals`}>
<div className="tm-meter-h"><span>Deal load</span><b>{m.deals}</b></div> <div className="tm-meter-h"><span>Deal load</span><b>{m.deals}</b></div>
<span className="tm-meter-track"><span className="tm-meter-fill" style={{ width: `${Math.round((m.deals / maxDeals) * 100)}%` }} /></span> <span className="tm-meter-track"><span className="tm-meter-fill" style={{ width: `${Math.round((m.deals / maxDeals) * 100)}%` }} /></span>
</div> </div>
<div className="tm-pcard-foot"> <div className="tm-pcard-foot">
<span className="tm-statuscell"><StatusDot status={toDot(m.status)} /> {STATUS_LABEL[m.status]}</span> <span className="tm-statuscell"><StatusDot status={toDot(m.status)} /> {STATUS_LABEL[m.status]}</span>
<span className="tm-lastactive">{m.lastActive}</span> <span className="tm-lastactive">{m.lastActive}</span>
</div> </div>
</article> </article>
); ))}
})}
</div> </div>
) : ( ) : (
<div className="card card-pad-0 tm-table"> <div className="card card-pad-0 tm-table">
<div className="tm-row tm-head"> <div className="tm-row tm-head">
<span>Member</span><span>Role</span><span>Status</span><span className="tm-c-num">Open deals</span><span className="tm-c-act" /> <span>Member</span><span>Roles</span><span>Status</span><span className="tm-c-num">Open deals</span><span className="tm-c-act" />
</div> </div>
{filtered.map((m) => { {filtered.map((m) => (
const role = roleById[m.roleId]; <div className="tm-row" key={m.id}>
const isOwner = m.roleId === "owner"; <div className="tm-member">
return ( <Avatar initials={m.initials} gradient={m.gradient} size={40} status={toDot(m.status)} />
<div className="tm-row" key={m.id}> <div className="tm-member-meta">
<div className="tm-member"> <div className="tm-name">{m.name}{m.isYou && <Pill tone="orange" style={{ marginLeft: 8 }}>You{m.isOwner ? " · Owner" : ""}</Pill>}</div>
<Avatar initials={m.initials} gradient={m.gradient} size={40} status={toDot(m.status)} /> <div className="tm-sub">{m.title ? `${m.title} · ` : ""}{m.email}</div>
<div className="tm-member-meta">
<div className="tm-name">{m.name}{isOwner && <Pill tone="orange" style={{ marginLeft: 8 }}>You · Owner</Pill>}</div>
<div className="tm-sub">{m.title} · {m.email}</div>
</div>
</div> </div>
<div className="tm-rolecell">
{isOwner ? (
<span className="tm-rolebadge" style={{ ["--rc" as string]: role?.color }}><Icon name="shield" size={13} /> {role?.name}</span>
) : (
<div className="tm-roleselect" style={{ ["--rc" as string]: role?.color }}>
<span className="tm-roledot" />
<select className="ds-select" value={m.roleId} onChange={(e) => changeRole(m.id, e.target.value)} aria-label={`Role for ${m.name}`}>
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</div>
)}
</div>
<div className="tm-statuscell">
<StatusDot status={toDot(m.status)} /><span>{STATUS_LABEL[m.status]}</span>
<span className="tm-lastactive">{m.lastActive}</span>
</div>
<div className="tm-c-num">{m.deals > 0 ? <b>{m.deals}</b> : <span className="tm-dash"></span>}</div>
<div className="tm-c-act"><RowMenu disabled={isOwner} onEdit={() => setEditing(m)} onRemove={() => setConfirmRemove(m)} /></div>
</div> </div>
); <div className="tm-rolecell"><RoleChips roleIds={m.roleIds} roleById={roleById} /></div>
})} <div className="tm-statuscell">
<StatusDot status={toDot(m.status)} /><span>{STATUS_LABEL[m.status]}</span>
<span className="tm-lastactive">{m.lastActive}</span>
</div>
<div className="tm-c-num">{m.deals > 0 ? <b>{m.deals}</b> : <span className="tm-dash"></span>}</div>
<div className="tm-c-act"><RowMenu disabled={m.isOwner} onEdit={() => setEditing(m)} onRemove={() => setConfirmRemove(m)} /></div>
</div>
))}
</div> </div>
)} )}
</div> </div>
@@ -257,31 +227,30 @@ export function TeamManagement() {
<div className="card tm-rolecard" key={r.id} style={{ ["--rc" as string]: r.color }}> <div className="card tm-rolecard" key={r.id} style={{ ["--rc" as string]: r.color }}>
<div className="tm-rolecard-head"> <div className="tm-rolecard-head">
<span className="tm-ring" style={{ ["--pct" as string]: pct }}> <span className="tm-ring" style={{ ["--pct" as string]: pct }}>
<span className="tm-ring-in"><Icon name={r.id === "owner" ? "star" : r.system ? "shield-check" : "shield"} size={17} /></span> <span className="tm-ring-in"><Icon name={r.isOwner ? "star" : r.system ? "shield-check" : "shield"} size={17} /></span>
</span> </span>
<div className="tm-rolecard-title"> <div className="tm-rolecard-title">
<div className="tm-rolecard-name">{r.name}{r.system && <Pill tone="muted" style={{ marginLeft: 8 }}>System</Pill>}</div> <div className="tm-rolecard-name">{r.name}{r.system && <Pill tone="muted" style={{ marginLeft: 8 }}>System</Pill>}</div>
<div className="tm-rolecard-sub">{countByRole[r.id] ?? 0} {(countByRole[r.id] ?? 0) === 1 ? "member" : "members"} · {r.perms.length}/{allPermissionIds.length} permissions</div> <div className="tm-rolecard-sub">{countByRole[r.id] ?? 0} {(countByRole[r.id] ?? 0) === 1 ? "member" : "members"} · {r.perms.length}/{allPermissionIds.length} permissions</div>
</div> </div>
</div> </div>
<p className="tm-rolecard-desc">{r.description}</p> {r.description && <p className="tm-rolecard-desc">{r.description}</p>}
<div className="tm-attire"> {r.attire.length > 0 && (
<div className="tm-attire-h"><Icon name="user" size={13} /> Attire &amp; appearance</div> <div className="tm-attire">
<ul className="tm-attire-list"> <div className="tm-attire-h"><Icon name="user" size={13} /> Attire &amp; appearance</div>
{r.attire.map((a) => ( <ul className="tm-attire-list">
<li key={a}><span className="tm-attire-dot" />{a}</li> {r.attire.map((a) => (<li key={a}><span className="tm-attire-dot" />{a}</li>))}
))} </ul>
</ul>
</div>
<div className="tm-overlap">
<span className="tm-overlap-ic"><Icon name="info" size={14} /></span>
<div>
<b>Common overlap</b>
<p>{r.overlap}</p>
</div> </div>
</div> )}
{r.overlap && (
<div className="tm-overlap">
<span className="tm-overlap-ic"><Icon name="info" size={14} /></span>
<div><b>Common overlap</b><p>{r.overlap}</p></div>
</div>
)}
<div className="tm-permlist"> <div className="tm-permlist">
{permissionGroups.map((g) => ( {permissionGroups.map((g) => (
@@ -289,14 +258,14 @@ export function TeamManagement() {
<div className="tm-permgroup-h"><Icon name={g.icon} size={13} /> {g.title}</div> <div className="tm-permgroup-h"><Icon name={g.icon} size={13} /> {g.title}</div>
{g.perms.map((p) => { {g.perms.map((p) => {
const granted = r.perms.includes(p.id); const granted = r.perms.includes(p.id);
const locked = r.id === "owner"; const locked = r.isOwner;
return ( return (
<div className={`tm-perm ${granted ? "on" : ""}`} key={p.id}> <div className={`tm-perm ${granted ? "on" : ""}`} key={p.id}>
<div className="tm-perm-meta"> <div className="tm-perm-meta">
<span className="tm-perm-label">{p.label}</span> <span className="tm-perm-label">{p.label}</span>
<span className="tm-perm-desc">{p.desc}</span> <span className="tm-perm-desc">{p.desc}</span>
</div> </div>
<Toggle checked={granted} disabled={locked} onChange={() => togglePerm(r.id, p.id)} label={p.label} /> <Toggle checked={granted} disabled={locked} onChange={() => togglePerm(r.id, p.id, !granted)} label={p.label} />
</div> </div>
); );
})} })}
@@ -323,23 +292,26 @@ export function TeamManagement() {
<div className="tm-empty"><span className="tm-empty-ic"><Icon name="mail" size={24} /></span><p>No pending invites. Everyone&apos;s on board.</p></div> <div className="tm-empty"><span className="tm-empty-ic"><Icon name="mail" size={24} /></span><p>No pending invites. Everyone&apos;s on board.</p></div>
) : ( ) : (
<div className="tm-invites"> <div className="tm-invites">
{invites.map((inv) => { {invites.map((inv) => (
const role = roleById[inv.roleId]; <div className="tm-inviterow" key={inv.id}>
return ( <span className="tm-invite-ic"><Icon name="mail" size={16} /></span>
<div className="tm-inviterow" key={inv.id}> <div className="tm-invite-meta">
<span className="tm-invite-ic"><Icon name="mail" size={16} /></span> <div className="tm-name">{inv.email}</div>
<div className="tm-invite-meta"> <div className="tm-sub">Invited by {inv.invitedBy} · {inv.sentAt}</div>
<div className="tm-name">{inv.email}</div>
<div className="tm-sub">Invited by {inv.invitedBy} · {inv.sentAt}</div>
</div>
<span className="tm-rolebadge" style={{ ["--rc" as string]: role?.color }}><Icon name="shield" size={13} /> {role?.name}</span>
<div className="tm-invite-actions">
<Btn variant="soft" size="sm" icon="refresh" onClick={() => toast.push({ tone: "success", title: "Invite resent", desc: `A fresh link was sent to ${inv.email}.` })}>Resend</Btn>
<Btn variant="ghost" size="sm" icon="x" onClick={() => { setInvites((l) => l.filter((x) => x.id !== inv.id)); toast.push({ tone: "info", title: "Invite revoked" }); }}>Revoke</Btn>
</div>
</div> </div>
); <RoleChips roleIds={inv.roleIds} roleById={roleById} />
})} <div className="tm-invite-actions">
<Btn variant="soft" size="sm" icon="refresh" onClick={async () => {
try { await team.resendInvite(inv.id); toast.push({ tone: "success", title: "Invite resent", desc: `A fresh link was sent to ${inv.email}.` }); }
catch (e) { toast.push({ tone: "error", title: "Couldn't resend", desc: (e as Error).message }); }
}}>Resend</Btn>
<Btn variant="ghost" size="sm" icon="x" onClick={async () => {
try { await team.revokeInvite(inv.id); toast.push({ tone: "info", title: "Invite revoked" }); }
catch (e) { toast.push({ tone: "error", title: "Couldn't revoke", desc: (e as Error).message }); }
}}>Revoke</Btn>
</div>
</div>
))}
</div> </div>
)} )}
</div> </div>
@@ -347,24 +319,30 @@ export function TeamManagement() {
<InviteModal <InviteModal
open={inviteOpen} open={inviteOpen}
roles={roles} roles={assignableRoles}
onClose={() => setInviteOpen(false)} onClose={() => setInviteOpen(false)}
onInvite={(email, roleId) => { onInvite={async (email, roleIds) => {
setInvites((l) => [{ id: `inv_${l.length + Date.now()}`, email, roleId, invitedBy: "James Carter", sentAt: "Just now" }, ...l]);
setInviteOpen(false); setInviteOpen(false);
setTab("invites"); try {
toast.push({ tone: "success", title: "Invitation sent", desc: `${email} was invited as ${roleById[roleId]?.name}.` }); await team.invite(email, roleIds);
setTab("invites");
toast.push({ tone: "success", title: "Invitation sent", desc: `${email} was invited with ${roleIds.length} role${roleIds.length === 1 ? "" : "s"}.` });
} catch (e) { toast.push({ tone: "error", title: "Couldn't send invite", desc: (e as Error).message }); }
}} }}
/> />
<EditMemberModal <EditMemberModal
member={editing} member={editing}
roles={roles} roles={assignableRoles}
onClose={() => setEditing(null)} onClose={() => setEditing(null)}
onSave={(id, patch) => { onSave={async (id, title, roleIds) => {
setMembers((list) => list.map((m) => (m.id === id ? { ...m, ...patch } : m))); const m = editing;
setEditing(null); setEditing(null);
toast.push({ tone: "success", title: "Member updated" }); if (!m) return;
try {
await team.updateMember(id, { title, roleIds });
toast.push({ tone: "success", title: "Member updated" });
} catch (e) { toast.push({ tone: "error", title: "Couldn't update member", desc: (e as Error).message }); }
}} }}
/> />
@@ -389,22 +367,22 @@ export function TeamManagement() {
} }
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
/* Stat card */ /* Role chips — show every role a member/invite holds */
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
function StatCard({ icon, tone, value, label, foot, bar }: { icon: string; tone: string; value: number; label: string; foot?: string; bar?: number }) { function RoleChips({ roleIds, roleById }: { roleIds: string[]; roleById: Record<string, UiRole> }) {
if (roleIds.length === 0) return <span className="tm-dash">No role</span>;
return ( return (
<div className="card tm-stat" style={{ ["--rc" as string]: tone }}> <span className="tm-rolechips">
<div className="tm-stat-head"> {roleIds.map((rid) => {
<span className="tm-stat-ic"><Icon name={icon} size={20} /></span> const role = roleById[rid];
<div className="tm-stat-body"> return (
<div className="tm-stat-val">{value}</div> <span className="tm-rolebadge" style={{ ["--rc" as string]: role?.color }} key={rid}>
<div className="tm-stat-lbl">{label}</div> <Icon name={role?.isOwner ? "star" : "shield"} size={12} /> {role?.name ?? rid}
</div> </span>
</div> );
{bar != null && <span className="tm-stat-bar"><span style={{ width: `${Math.round(bar * 100)}%` }} /></span>} })}
{foot && <div className="tm-stat-foot"><span className="tm-stat-dot" />{foot}</div>} </span>
</div>
); );
} }
@@ -423,7 +401,7 @@ function RowMenu({ onEdit, onRemove, disabled }: { onEdit: () => void; onRemove:
<div className="tm-menu-scrim" onClick={() => setOpen(false)} /> <div className="tm-menu-scrim" onClick={() => setOpen(false)} />
<div className="tm-menu-pop" role="menu"> <div className="tm-menu-pop" role="menu">
<button onClick={() => { setOpen(false); onEdit(); }}><Icon name="edit" size={15} /> Edit member</button> <button onClick={() => { setOpen(false); onEdit(); }}><Icon name="edit" size={15} /> Edit member</button>
<button onClick={() => { setOpen(false); onEdit(); }}><Icon name="shield" size={15} /> Change role</button> <button onClick={() => { setOpen(false); onEdit(); }}><Icon name="shield" size={15} /> Change roles</button>
<div className="tm-menu-sep" /> <div className="tm-menu-sep" />
<button className="danger" onClick={() => { setOpen(false); onRemove(); }}><Icon name="trash" size={15} /> Remove</button> <button className="danger" onClick={() => { setOpen(false); onRemove(); }}><Icon name="trash" size={15} /> Remove</button>
</div> </div>
@@ -433,21 +411,37 @@ function RowMenu({ onEdit, onRemove, disabled }: { onEdit: () => void; onRemove:
); );
} }
/* ---------------------------------------------------------- */
/* Multi-role picker — checkbox chips, ≥1 required */
/* ---------------------------------------------------------- */
function RolePicker({ roles, selected, onToggle }: { roles: UiRole[]; selected: string[]; onToggle: (id: string) => void }) {
return (
<div className="tm-rolepicker">
{roles.map((r) => {
const on = selected.includes(r.id);
return (
<button type="button" key={r.id} className={`tm-rolepick ${on ? "on" : ""}`} style={{ ["--rc" as string]: r.color }} onClick={() => onToggle(r.id)} aria-pressed={on}>
<span className="tm-rolepick-check">{on && <Icon name="check" size={12} />}</span>
<span className="tm-roledot" /> {r.name}
</button>
);
})}
</div>
);
}
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
/* Invite modal */ /* Invite modal */
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
function InviteModal({ open, roles, onClose, onInvite }: { open: boolean; roles: Role[]; onClose: () => void; onInvite: (email: string, roleId: string) => void }) { function InviteModal({ open, roles, onClose, onInvite }: { open: boolean; roles: UiRole[]; onClose: () => void; onInvite: (email: string, roleIds: string[]) => void }) {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [roleId, setRoleId] = useState("sales-rep"); const [roleIds, setRoleIds] = useState<string[]>([]);
const valid = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.trim()); const valid = EMAIL_RE.test(email.trim()) && roleIds.length > 0;
const role = roles.find((r) => r.id === roleId);
function submit() { function toggle(id: string) { setRoleIds((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])); }
if (!valid) return; function submit() { if (!valid) return; onInvite(email.trim(), roleIds); setEmail(""); setRoleIds([]); }
onInvite(email.trim(), roleId);
setEmail(""); setRoleId("sales-rep");
}
return ( return (
<Modal <Modal
@@ -464,38 +458,32 @@ function InviteModal({ open, roles, onClose, onInvite }: { open: boolean; roles:
} }
> >
<div className="tm-form"> <div className="tm-form">
<Field label="Work email" required hint={email && !valid ? undefined : "We'll send the invite here."} error={email && !valid ? "Enter a valid email address." : undefined}> <Field label="Work email" required hint={email && !EMAIL_RE.test(email.trim()) ? undefined : "We'll send the invite here."} error={email && !EMAIL_RE.test(email.trim()) ? "Enter a valid email address." : undefined}>
<input className="ds-input" type="email" placeholder="name@company.com" value={email} onChange={(e) => setEmail(e.target.value)} /> <input className="ds-input" type="email" placeholder="name@company.com" value={email} onChange={(e) => setEmail(e.target.value)} />
</Field> </Field>
<Field label="Assign a role" required hint="Roles decide what this person can see and do."> <Field label="Assign roles" required hint="Pick one or more. Roles decide what this person can see and do.">
<select className="ds-select" value={roleId} onChange={(e) => setRoleId(e.target.value)}> <RolePicker roles={roles} selected={roleIds} onToggle={toggle} />
{roles.filter((r) => r.id !== "owner").map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</Field> </Field>
{role && (
<div className="tm-roleprev" style={{ ["--rc" as string]: role.color }}>
<div className="tm-roleprev-h"><span className="tm-roledot" /> {role.name}<span className="tm-roleprev-count">{role.perms.length} permissions</span></div>
<p>{role.description}</p>
</div>
)}
</div> </div>
</Modal> </Modal>
); );
} }
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
/* Edit member modal */ /* Edit member modal — job title + multi-role */
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
function EditMemberModal({ member, roles, onClose, onSave }: { member: Member | null; roles: Role[]; onClose: () => void; onSave: (id: string, patch: Partial<Member>) => void }) { function EditMemberModal({ member, roles, onClose, onSave }: { member: UiMember | null; roles: UiRole[]; onClose: () => void; onSave: (id: string, title: string, roleIds: string[]) => void }) {
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [roleId, setRoleId] = useState("sales-rep"); const [roleIds, setRoleIds] = useState<string[]>([]);
// Sync local form when a different member is opened.
const key = member?.id ?? ""; const key = member?.id ?? "";
useEffect(() => { if (member) { setTitle(member.title); setRoleId(member.roleId); } }, [key]); // eslint-disable-line react-hooks/exhaustive-deps, react-hooks/set-state-in-effect useEffect(() => { if (member) { setTitle(member.title); setRoleIds(member.roleIds); } }, [key]); // eslint-disable-line react-hooks/exhaustive-deps
if (!member) return null; if (!member) return null;
const valid = roleIds.length > 0;
function toggle(id: string) { setRoleIds((s) => (s.includes(id) ? s.filter((x) => x !== id) : [...s, id])); }
return ( return (
<Modal <Modal
open={!!member} open={!!member}
@@ -506,7 +494,7 @@ function EditMemberModal({ member, roles, onClose, onSave }: { member: Member |
footer={ footer={
<> <>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn> <Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn icon="check" onClick={() => onSave(member.id, { title, roleId })}>Save changes</Btn> <Btn icon="check" disabled={!valid} onClick={() => onSave(member.id, title, roleIds)}>Save changes</Btn>
</> </>
} }
> >
@@ -519,12 +507,10 @@ function EditMemberModal({ member, roles, onClose, onSave }: { member: Member |
</div> </div>
</div> </div>
<Field label="Job title"> <Field label="Job title">
<input className="ds-input" value={title} onChange={(e) => setTitle(e.target.value)} /> <input className="ds-input" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="e.g. Senior Sales Rep" />
</Field> </Field>
<Field label="Role" hint="Changing the role updates this member's permissions instantly."> <Field label="Roles" required hint="A member can hold several roles — permissions are the union of all of them.">
<select className="ds-select" value={roleId} onChange={(e) => setRoleId(e.target.value)}> <RolePicker roles={roles} selected={roleIds} onToggle={toggle} />
{roles.filter((r) => r.id !== "owner").map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</Field> </Field>
</div> </div>
</Modal> </Modal>
+42 -9
View File
@@ -1,10 +1,33 @@
"use client"; "use client";
import { Sun, Moon, ChevronDown } from "lucide-react"; import { useState } from "react";
import { useRouter } from "next/navigation";
import { Sun, Moon, ChevronDown, LogOut } from "lucide-react";
import { useAuth } from "@abe-kap/appshell-sdk/react";
import { Icon } from "./ui"; import { Icon } from "./ui";
import { user } from "./account-data"; import { user } from "./account-data";
function initialsOf(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
}
export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "light"; onToggle: () => void; title: string; subtitle: string }) { export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "light"; onToggle: () => void; title: string; subtitle: string }) {
// When signed in through the Shell, show the real identity from the App Context
// Envelope; otherwise fall back to the static demo user.
const router = useRouter();
const { user: me, logout, context } = useAuth();
const [menuOpen, setMenuOpen] = useState(false);
const roleLabel = context?.scope?.role ? context.scope.role.charAt(0).toUpperCase() + context.scope.role.slice(1) : "";
const name = me?.displayName || user.name;
const initials = me ? initialsOf(me.displayName) : user.initials;
const secondary = me?.email || roleLabel || user.role;
async function signOut() {
setMenuOpen(false);
try { await logout(); } catch { /* ignore */ }
router.replace("/portal/login");
}
return ( return (
<header className="dash-topbar"> <header className="dash-topbar">
<div className="dash-title"> <div className="dash-title">
@@ -20,14 +43,24 @@ export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "
<Icon name="bell" size={18} /> <Icon name="bell" size={18} />
<span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} /> <span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} />
</button> </button>
<button className="top-user"> <div className="top-user-wrap" style={{ position: "relative" }}>
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{user.initials}</span> <button className="top-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
<div style={{ textAlign: "left" }}> <span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
<div className="nm">{user.name}</div> <div style={{ textAlign: "left", minWidth: 0 }}>
<div className="rl">{user.role}</div> <div className="nm">{name}</div>
</div> <div className="rl" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 150 }}>{secondary}</div>
<ChevronDown size={16} /> </div>
</button> <ChevronDown size={16} />
</button>
{menuOpen && (
<>
<div className="tm-menu-scrim" onClick={() => setMenuOpen(false)} />
<div className="tm-menu-pop" role="menu">
<button className="danger" onClick={signOut}><LogOut size={15} /> Sign out</button>
</div>
</>
)}
</div>
</div> </div>
</header> </header>
); );
+2 -7
View File
@@ -145,18 +145,13 @@ export function startSocial(provider: "google" | "microsoft" | "apple"): boolean
if (url) { window.location.href = url; return true; } if (url) { window.location.href = url; return true; }
return false; return false;
} }
export function SocialButtons({ onPick, verb = "Continue" }: { onPick: (p: "google" | "microsoft" | "apple") => void; verb?: string }) { export function SocialButtons({ onPick, verb = "Continue" }: { onPick: (p: "google") => void; verb?: string }) {
// Only Google is offered (Microsoft/Apple intentionally hidden).
return ( return (
<div className="col gap-3"> <div className="col gap-3">
<button className="btn btn-oauth" type="button" onClick={() => onPick("google")}> <button className="btn btn-oauth" type="button" onClick={() => onPick("google")}>
<GoogleMark /> {verb} with Google <GoogleMark /> {verb} with Google
</button> </button>
<button className="btn btn-oauth" type="button" onClick={() => onPick("microsoft")}>
<MicrosoftMark /> {verb} with Microsoft / Outlook
</button>
<button className="btn btn-oauth" type="button" onClick={() => onPick("apple")}>
<AppleMark /> {verb} with Apple
</button>
</div> </div>
); );
} }
+165 -37
View File
@@ -8,7 +8,12 @@ import {
RememberDevice, OtpBoxes, ResendLink, SocialButtons, startSocial, RememberDevice, OtpBoxes, ResendLink, SocialButtons, startSocial,
PasswordStrength, PasswordStrength,
} from "./bits"; } from "./bits";
import { lookupAccount, type Account } from "./data"; import { lookupAccount, maskEmail, type Account } from "./data";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "@/lib/appshell";
// Map the portal's social button ids to Supabase OAuth provider ids.
const OAUTH_PROVIDER: Record<string, string> = { google: "google", microsoft: "azure", apple: "apple" };
type Step = type Step =
| "identify" | "connecting" | "notfound" | "primary" | "passkey" | "password" | "identify" | "connecting" | "notfound" | "primary" | "passkey" | "password"
@@ -19,12 +24,26 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function LoginFlow() { export function LoginFlow() {
const router = useRouter(); const router = useRouter();
const { login, loginWithOAuth, completeOAuthLogin } = useAuth();
const { ready, sdk } = useAppShell();
const [step, setStep] = useState<Step>("identify"); const [step, setStep] = useState<Step>("identify");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [account, setAccount] = useState<Account | null>(null); const [account, setAccount] = useState<Account | null>(null);
const [provider, setProvider] = useState<string>(""); const [provider, setProvider] = useState<string>("");
const [remember, setRemember] = useState(false); const [remember, setRemember] = useState(false);
const [flash, setFlash] = useState<string>(""); const [flash, setFlash] = useState<string>("");
const [otpChannel, setOtpChannel] = useState<"email" | "sms">("email");
// Minimal account stand-in for passwordless entry points (Shell mode).
function blankAccount(): Account {
return { firstName: "", name: "", initials: "", hasPasskey: false, hasTotp: false, hasPush: false, maskedEmail: maskEmail(email || ""), maskedPhone: "", maskedWa: "" };
}
// Direct "sign in with phone" → the one-time-code screen, SMS preselected.
function startPhoneLogin() {
setAccount(blankAccount());
setOtpChannel("sms");
replace("otp");
}
/* ---- history hash sync ---- */ /* ---- history hash sync ---- */
function push(s: Step) { function push(s: Step) {
@@ -47,6 +66,34 @@ export function LoginFlow() {
return () => window.removeEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop);
}, []); }, []);
/* ---- OAuth return: finish the redirect started by loginWithOAuth ---- */
useEffect(() => {
if (!isShellConfigured()) return;
const code = new URLSearchParams(window.location.search).get("code");
if (!code) return;
// Wait until the SDK has booted — completeOAuthLogin no-ops (returns null) if
// sdk.auth isn't ready yet, and this effect only re-runs when `ready` flips.
if (!ready) { setStep("connecting"); return; }
setStep("connecting");
completeOAuthLogin(code)
.then(async (ace) => {
if (!ace) { replace("identify"); return; }
// First-time Google user (no CRM profile yet) → complete onboarding; an
// existing profile → straight to the dashboard.
let registered = false;
try {
const st = await sdk.query<{ registered: boolean }>("crm.account.registrationStatus");
registered = !!st?.registered;
} catch { registered = false; }
window.history.replaceState({}, "", "/portal/login");
router.replace(registered ? "/dashboard" : "/portal/onboarding");
})
.catch(() => {
setFlash("Google sign-in didn't complete. Please try again.");
replace("identify");
});
}, [ready, completeOAuthLogin, router, sdk]);
/* ---- auth resolution ---- */ /* ---- auth resolution ---- */
function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") { function afterAuth(factor: "password" | "passkey" | "otp" | "totp" | "push" | "social") {
if (factor === "password") { setFlash(""); push("otp"); return; } if (factor === "password") { setFlash(""); push("otp"); return; }
@@ -56,6 +103,14 @@ export function LoginFlow() {
} }
function onSocial(p: "google" | "microsoft" | "apple") { function onSocial(p: "google" | "microsoft" | "apple") {
if (isShellConfigured()) {
// Real OAuth: appshell redirects to the provider; we finish on return (effect above).
setProvider(p);
push("connecting");
loginWithOAuth(OAUTH_PROVIDER[p] ?? p, `${window.location.origin}/portal/login`)
.catch(() => { setFlash("Couldn't start sign-in with that provider."); replace("identify"); });
return;
}
const real = startSocial(p); const real = startSocial(p);
if (real) return; // redirected away if (real) return; // redirected away
setProvider(p); setProvider(p);
@@ -65,6 +120,20 @@ export function LoginFlow() {
function identifyEmail() { function identifyEmail() {
if (!EMAIL_RE.test(email)) return; if (!EMAIL_RE.test(email)) return;
if (isShellConfigured()) {
// No mock account directory when the Shell is live: any valid email goes to the
// password screen; the BFF/Supabase decides if the account exists.
const name = email.split("@")[0];
setAccount({
firstName: name.charAt(0).toUpperCase() + name.slice(1),
name, initials: name.slice(0, 2).toUpperCase(),
hasPasskey: false, hasTotp: false, hasPush: false,
maskedEmail: maskEmail(email), maskedPhone: "", maskedWa: "",
});
setOtpChannel("email");
replace("password");
return;
}
push("connecting"); push("connecting");
setProvider(""); setProvider("");
setTimeout(() => { setTimeout(() => {
@@ -78,14 +147,18 @@ export function LoginFlow() {
/* =================================================================== */ /* =================================================================== */
return ( return (
<div className="card anim-fade-up" key={step}> <div className="card anim-fade-up" key={step}>
{step === "identify" && <Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} toRegister={() => router.push("/portal/register")} />} {step === "identify" && (
<>
{flash && <div style={{ marginBottom: 16 }}><FlashNote tone="error">{flash}</FlashNote></div>}
<Identify email={email} setEmail={setEmail} onSocial={onSocial} onEmail={identifyEmail} onPhone={startPhoneLogin} toRegister={() => router.push("/portal/register")} />
</>
)}
{step === "connecting" && ( {step === "connecting" && (
<div className="interstitial"> <div className="interstitial">
<Spinner lg /> <Spinner lg />
<div> <div>
<h1 style={{ fontSize: 20 }}>{provider ? `Connecting to ${cap(provider)}` : "Looking up your account…"}</h1> <h1 style={{ fontSize: 20 }}>{provider ? `Connecting to ${cap(provider)}` : "Looking up your account…"}</h1>
{provider && <p className="sub" style={{ marginTop: 6 }}>Demo mode no provider keys configured.</p>}
</div> </div>
</div> </div>
)} )}
@@ -124,11 +197,11 @@ export function LoginFlow() {
)} )}
{step === "password" && account && ( {step === "password" && account && (
<Password account={account} flash={flash} onBack={back} onForgot={() => push("fp_confirm")} onOtp={() => replace("otp")} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} /> <Password account={account} email={email} login={login} onAuthenticated={() => router.replace("/dashboard")} flash={flash} onBack={back} onForgot={() => push("fp_confirm")} onOtp={() => { setOtpChannel("email"); replace("otp"); }} onLocked={() => setFlash("This account is temporarily locked.")} onOk={() => afterAuth("password")} />
)} )}
{step === "otp" && account && ( {step === "otp" && account && (
<OtpVerify account={account} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} /> <OtpVerify account={account} email={email} initialChannel={otpChannel} remember={remember} setRemember={setRemember} onBack={back} onVerified={() => afterAuth("otp")} onAuthenticated={() => router.replace("/dashboard")} />
)} )}
{step === "another" && account && ( {step === "another" && account && (
@@ -192,9 +265,9 @@ export function LoginFlow() {
/* ============================ screens ============================ */ /* ============================ screens ============================ */
function Identify({ email, setEmail, onSocial, onEmail, toRegister }: { function Identify({ email, setEmail, onSocial, onEmail, onPhone, toRegister }: {
email: string; setEmail: (v: string) => void; email: string; setEmail: (v: string) => void;
onSocial: (p: "google" | "microsoft" | "apple") => void; onEmail: () => void; toRegister: () => void; onSocial: (p: "google") => void; onEmail: () => void; onPhone: () => void; toRegister: () => void;
}) { }) {
const [showEmail, setShowEmail] = useState(false); const [showEmail, setShowEmail] = useState(false);
const valid = EMAIL_RE.test(email); const valid = EMAIL_RE.test(email);
@@ -225,6 +298,9 @@ function Identify({ email, setEmail, onSocial, onEmail, toRegister }: {
</button> </button>
</form> </form>
)} )}
{isShellConfigured() && (
<button className="link" style={{ marginTop: 14, display: "block" }} onClick={onPhone}><Icon name="sms" size={15} /> Sign in with a phone number instead</button>
)}
</div> </div>
<p className="foot-note">New homeowner or contractor? <button className="link" onClick={toRegister}>Register here</button></p> <p className="foot-note">New homeowner or contractor? <button className="link" onClick={toRegister}>Register here</button></p>
@@ -233,7 +309,6 @@ function Identify({ email, setEmail, onSocial, onEmail, toRegister }: {
<span><Icon name="shield" size={13} /> Licensed &amp; Insured</span> <span><Icon name="shield" size={13} /> Licensed &amp; Insured</span>
<span><Icon name="check" size={13} /> Drone-Powered</span> <span><Icon name="check" size={13} /> Drone-Powered</span>
</div> </div>
<p className="demo-hint">Demo: any email signs in; an email starting with new shows not found.</p>
</div> </div>
); );
} }
@@ -268,22 +343,32 @@ function Passkey({ account, onBack, onPassword, onAnother, onSuccess, onFail }:
<button className="link" onClick={onPassword}>Use your password instead</button> <button className="link" onClick={onPassword}>Use your password instead</button>
<button className="link" onClick={onAnother}>Try another way</button> <button className="link" onClick={onAnother}>Try another way</button>
</div> </div>
<p className="demo-hint">Demo: passkey always succeeds here.</p>
<button hidden onClick={onFail} /> <button hidden onClick={onFail} />
</div> </div>
); );
} }
function Password({ account, flash, onBack, onForgot, onOtp, onLocked, onOk }: { function Password({ account, email, login, onAuthenticated, flash, onBack, onForgot, onOtp, onLocked, onOk }: {
account: Account; flash: string; onBack: () => void; onForgot: () => void; onOtp: () => void; onLocked: () => void; onOk: () => void; account: Account; email: string; login: ReturnType<typeof useAuth>["login"]; onAuthenticated: () => void;
flash: string; onBack: () => void; onForgot: () => void; onOtp: () => void; onLocked: () => void; onOk: () => void;
}) { }) {
const [pw, setPw] = useState(""); const [pw, setPw] = useState("");
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const [err, setErr] = useState(""); const [err, setErr] = useState("");
function submit(e: React.FormEvent) { const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (!pw || busy) return;
if (isShellConfigured()) {
// Real sign-in through appshell → BFF. A resolved ACE means fully authenticated
// (Supabase handles any MFA), so go straight to the dashboard.
setErr(""); setBusy(true);
try { await login({ email, password: pw }); onAuthenticated(); }
catch { setErr("locked"); onLocked(); }
finally { setBusy(false); }
return;
}
if (pw.toLowerCase() === "wrong") { setErr("locked"); onLocked(); return; } if (pw.toLowerCase() === "wrong") { setErr("locked"); onLocked(); return; }
if (!pw) return;
onOk(); onOk();
} }
return ( return (
@@ -304,46 +389,92 @@ function Password({ account, flash, onBack, onForgot, onOtp, onLocked, onOk }: {
</button> </button>
</div> </div>
</div> </div>
<button className="btn btn-primary" style={{ marginTop: 14 }}>Sign in <Icon name="arrowR" size={16} /></button> <button className="btn btn-primary" style={{ marginTop: 14 }} disabled={busy}>{busy ? "Signing in…" : <>Sign in <Icon name="arrowR" size={16} /></>}</button>
</form> </form>
<div className="row between" style={{ marginTop: 16 }}> <div className="row between" style={{ marginTop: 16 }}>
<button className="link" onClick={onForgot}>Forgot password?</button> <button className="link" onClick={onForgot}>Forgot password?</button>
<button className="link" onClick={onOtp}>Sign in using email OTP</button> <button className="link" onClick={onOtp}>Sign in with a one-time code</button>
</div> </div>
<p className="demo-hint">Demo: any password works; type wrong to see the lockout. Password always needs 2-step next.</p>
</div> </div>
); );
} }
function OtpVerify({ account, remember, setRemember, onBack, onVerified }: { function OtpVerify({ account, email, initialChannel = "email", remember, setRemember, onBack, onVerified, onAuthenticated }: {
account: Account; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void; account: Account; email: string; initialChannel?: "email" | "sms"; remember: boolean; setRemember: (v: boolean) => void; onBack: () => void; onVerified: () => void; onAuthenticated: () => void;
}) { }) {
const [channel, setChannel] = useState<"email" | "sms" | "wa">("email"); const { sendEmailOtp, verifyEmailOtp, sendPhoneOtp, verifyPhoneOtp } = useAuth();
const [error, setError] = useState(false); const shell = isShellConfigured();
const target = channel === "email" ? account.maskedEmail : channel === "sms" ? account.maskedPhone : account.maskedWa; // In Shell mode only email + SMS are real Supabase OTP channels; hide WhatsApp.
function complete(code: string) { const [channel, setChannel] = useState<"email" | "sms" | "wa">(initialChannel);
if (code === "000000") { setError(true); return; } const [phone, setPhone] = useState("");
setError(false); onVerified(); const [sent, setSent] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string>("");
const target = channel === "email" ? account.maskedEmail : channel === "sms" ? (phone || account.maskedPhone) : account.maskedWa;
const PHONE_RE = /^\+[1-9]\d{6,14}$/;
// Real Supabase OTP: auto-send the email code when this screen opens.
useEffect(() => {
if (shell && channel === "email" && !sent) void send();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shell, channel]);
async function send() {
setError("");
try {
if (channel === "email") { await sendEmailOtp(email); setSent(true); }
else if (channel === "sms") {
if (!PHONE_RE.test(phone)) { setError("Enter your phone in international format, e.g. +14155550100."); return; }
await sendPhoneOtp(phone); setSent(true);
}
} catch (e) { setError((e as Error).message); }
} }
async function complete(code: string) {
if (!shell) { if (code === "000000") { setError("bad"); return; } setError(""); onVerified(); return; }
setBusy(true); setError("");
try {
if (channel === "email") await verifyEmailOtp(email, code);
else await verifyPhoneOtp(phone, code);
onAuthenticated();
} catch { setError("Incorrect or expired code. Please try again."); }
finally { setBusy(false); }
}
const showBoxes = !shell || sent;
return ( return (
<div> <div>
<StepBack onClick={onBack} /> <StepBack onClick={onBack} />
<h1>2-step verification</h1> <h1>{shell ? "One-time passcode" : "2-step verification"}</h1>
<p className="sub">Enter the 6-digit code we sent you to finish signing in.</p> <p className="sub">{shell ? "We'll text or email you a 6-digit code to sign in." : "Enter the 6-digit code we sent you to finish signing in."}</p>
<div className="seg" style={{ margin: "16px 0 14px" }}> <div className="seg" style={{ margin: "16px 0 14px" }}>
<button className={channel === "email" ? "on" : ""} onClick={() => { setChannel("email"); setError(false); }}><Icon name="mail" size={15} /> Email</button> <button className={channel === "email" ? "on" : ""} onClick={() => { setChannel("email"); setError(""); setSent(false); }}><Icon name="mail" size={15} /> Email</button>
<button className={channel === "sms" ? "on" : ""} onClick={() => { setChannel("sms"); setError(false); }}><Icon name="sms" size={15} /> SMS</button> <button className={channel === "sms" ? "on" : ""} onClick={() => { setChannel("sms"); setError(""); setSent(false); }}><Icon name="sms" size={15} /> SMS</button>
<button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(false); }}><Icon name="whatsapp" size={15} /> WhatsApp</button> {!shell && <button className={channel === "wa" ? "on" : ""} onClick={() => { setChannel("wa"); setError(""); }}><Icon name="whatsapp" size={15} /> WhatsApp</button>}
</div> </div>
<p className="faint" style={{ fontSize: 12.5, marginBottom: 12 }}>Code sent to {target}</p>
<OtpBoxes onComplete={complete} error={error} /> {shell && channel === "sms" && !sent ? (
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code. Please try again.</FlashNote></div>} <form onSubmit={(e) => { e.preventDefault(); void send(); }}>
<div className="field">
<label className="label" htmlFor="otpphone">Phone number</label>
<input id="otpphone" className="input" type="tel" autoFocus placeholder="+1 415 555 0100" value={phone} onChange={(e) => setPhone(e.target.value)} />
</div>
<button className="btn btn-primary" style={{ marginTop: 12 }} disabled={busy}>Send code <Icon name="arrowR" size={16} /></button>
</form>
) : (
<>
<p className="faint" style={{ fontSize: 12.5, marginBottom: 12 }}>Code sent to {target}</p>
{showBoxes && <OtpBoxes onComplete={(c) => void complete(c)} error={!!error} />}
</>
)}
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">{error === "bad" ? "Incorrect code. Please try again." : error}</FlashNote></div>}
<div className="row between" style={{ margin: "14px 0" }}> <div className="row between" style={{ margin: "14px 0" }}>
<ResendLink seconds={30} /> {shell ? <button className="link" onClick={() => void send()} disabled={busy}>Resend code</button> : <ResendLink seconds={30} />}
<span /> <span />
</div> </div>
<RememberDevice checked={remember} onChange={setRemember} note="2-step is still required at each sign-in." /> <RememberDevice checked={remember} onChange={setRemember} note="2-step is still required at each sign-in." />
<p className="demo-hint">Demo: any 6 digits verify; 000000 shows an error.</p>
</div> </div>
); );
} }
@@ -398,7 +529,6 @@ function Totp({ remember, setRemember, onBack, onVerified }: { remember: boolean
{setupKey ? "Enter a 6-digit code instead" : "Enter setup key instead"} {setupKey ? "Enter a 6-digit code instead" : "Enter setup key instead"}
</button> </button>
<RememberDevice checked={remember} onChange={setRemember} /> <RememberDevice checked={remember} onChange={setRemember} />
<p className="demo-hint">Demo: any 6 digits verify; 000000 fails.</p>
</div> </div>
); );
} }
@@ -417,7 +547,6 @@ function PushApprove({ onBack, onApproved, onCancel }: { onBack: () => void; onA
<p className="sub" style={{ textAlign: "center" }}>{approved ? "Your sign-in was approved." : "We sent a notification to your mobile app. Approve it to continue."}</p> <p className="sub" style={{ textAlign: "center" }}>{approved ? "Your sign-in was approved." : "We sent a notification to your mobile app. Approve it to continue."}</p>
<div className="interstitial">{approved ? <Icon name="check" size={30} /> : <Spinner lg />}</div> <div className="interstitial">{approved ? <Icon name="check" size={30} /> : <Spinner lg />}</div>
{!approved && <button className="btn" onClick={onCancel}>Cancel</button>} {!approved && <button className="btn" onClick={onCancel}>Cancel</button>}
<p className="demo-hint">Demo: auto-approves after ~3 seconds.</p>
</div> </div>
); );
} }
@@ -501,7 +630,6 @@ function ForgotCode({ onBack, onOk }: { onBack: () => void; onOk: () => void })
<div style={{ marginTop: 18 }}><OtpBoxes onComplete={(c) => (c === "000000" ? setError(true) : onOk())} error={error} /></div> <div style={{ marginTop: 18 }}><OtpBoxes onComplete={(c) => (c === "000000" ? setError(true) : onOk())} error={error} /></div>
{error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>} {error && <div style={{ marginTop: 12 }}><FlashNote tone="error">Incorrect code.</FlashNote></div>}
<div style={{ marginTop: 14 }}><ResendLink seconds={30} /></div> <div style={{ marginTop: 14 }}><ResendLink seconds={30} /></div>
<p className="demo-hint">Demo: any 6 digits work; 000000 fails.</p>
</div> </div>
); );
} }
+89 -31
View File
@@ -5,19 +5,35 @@ import { useRouter } from "next/navigation";
import { Icon, Avatar } from "./icons"; import { Icon, Avatar } from "./icons";
import { import {
StepBack, FlashNote, Badge, OtpBoxes, ResendLink, RememberDevice, StepBack, FlashNote, Badge, OtpBoxes, ResendLink, RememberDevice,
SocialButtons, startSocial, PasswordStrength, LegalModal, PasswordStrength, LegalModal,
} from "./bits"; } from "./bits";
import { import {
countryCodes, relationshipOptions, addressCountries, countryCodes, relationshipOptions, addressCountries,
TERMS, PRIVACY, passwordStrength, type AddrCountry, TERMS, PRIVACY, passwordStrength, type AddrCountry,
} from "./data"; } from "./data";
import { useAuth, useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "@/lib/appshell";
type Addr = { line1?: string; line2?: string; city?: string; state?: string; postalCode?: string; country?: string; locality?: string };
const STEPS = ["Account", "Verify", "Address"]; const STEPS = ["Account", "Verify", "Address"];
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function RegisterFlow() { export function RegisterFlow({ mode = "register" }: { mode?: "register" | "onboard" }) {
// "onboard" = an already-authenticated OAuth (Google) user completing their CRM
// profile: email is skipped (Google-verified), the auth account already exists so
// we only SET a password + persist the profile, and the OTP-verify step is skipped.
const onboard = mode === "onboard";
const router = useRouter(); const router = useRouter();
const { register, setPassword, getUserEmail } = useAuth();
const { sdk } = useAppShell();
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const [submitErr, setSubmitErr] = useState("");
// address (lifted from StepAddress/AddressBlock so finish() can persist it)
const [regAddr, setRegAddr] = useState<Addr>({});
const [mailAddr, setMailAddr] = useState<Addr>({});
const [mailingSame, setMailingSame] = useState(true);
// shared form state // shared form state
const [sso, setSso] = useState<{ provider: string; email: string } | null>(null); const [sso, setSso] = useState<{ provider: string; email: string } | null>(null);
@@ -38,6 +54,15 @@ export function RegisterFlow() {
const [emailVerified, setEmailVerified] = useState(false); const [emailVerified, setEmailVerified] = useState(false);
const [phoneVerified, setPhoneVerified] = useState(false); const [phoneVerified, setPhoneVerified] = useState(false);
// Onboarding: prefill the verified email from the Google session (the ACE omits it).
useEffect(() => {
if (!onboard) return;
getUserEmail().then((e) => { if (e) setEmail(e); }).catch(() => { /* ignore */ });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onboard]);
const STEP_LABELS = onboard ? ["Profile", "Address"] : STEPS;
const isSelf = relationship === "Customer" || relationship === "Owner"; const isSelf = relationship === "Customer" || relationship === "Owner";
const isEmployee = relationship === "Employee"; const isEmployee = relationship === "Employee";
const country = countryCodes.find((c) => c.code === cc)!; const country = countryCodes.find((c) => c.code === cc)!;
@@ -51,24 +76,58 @@ export function RegisterFlow() {
const step2Valid = emailVerified && phoneVerified; const step2Valid = emailVerified && phoneVerified;
function finish() { async function finish() {
const finalEmail = sso?.email || email;
const profile = { const profile = {
name: `${first} ${last}`.trim(), name: `${first} ${last}`.trim(),
initials: `${first[0] ?? ""}${last[0] ?? ""}`.toUpperCase(), initials: `${first[0] ?? ""}${last[0] ?? ""}`.toUpperCase(),
email: sso?.email || email, email: finalEmail,
isAllottee: isSelf, isAllottee: isSelf,
allotteeNames: isSelf ? null : `${alloeFirst} ${alloeLast}`.trim(), allotteeNames: isSelf ? null : `${alloeFirst} ${alloeLast}`.trim(),
}; };
try { localStorage.setItem("lup_profile", JSON.stringify(profile)); } catch { /* ignore */ } try { localStorage.setItem("lup_profile", JSON.stringify(profile)); } catch { /* ignore */ }
if (isShellConfigured()) {
setSubmitErr("");
// 1) Auth account. Onboarding users are already authenticated via Google — set
// a password so email+password login works too. Everyone else registers now.
if (onboard) {
try { if (pw) await setPassword(pw); } catch { /* non-fatal — the profile still saves */ }
} else if (!sso) {
try { await register(finalEmail, pw); }
catch { setSubmitErr("We couldn't create that account. The email may already be registered."); return; }
}
// 2) Persist the full CRM registration payload to be-crm (name, phone, persona,
// allottee, both addresses, consent). Non-fatal: the auth account exists either way.
try {
const mailing = mailingSame ? regAddr : mailAddr;
await sdk.command("crm.account.register", {
email: finalEmail,
firstName: first, lastName: last,
phoneCc: cc, phoneNumber: phone.replace(/\D/g, ""),
persona: relationship,
isAllottee: isSelf,
...(isSelf ? {} : { allotteeId: alloeNo, allotteeFirstName: alloeFirst, allotteeLastName: alloeLast }),
registeredAddress: regAddr,
mailingAddress: mailing,
mailingSameAsRegistered: mailingSame,
consentTerms: termsOk, consentPrivacy: privacyOk,
});
} catch {
if (onboard) { setSubmitErr("Couldn't save your profile. Please try again."); return; }
// register mode: non-fatal — the auth account exists; the profile can be filled in later.
}
}
router.push("/dashboard"); router.push("/dashboard");
} }
return ( return (
<div className="card anim-fade-up"> <div className="card anim-fade-up">
<Stepper current={step} /> <Stepper current={step} labels={STEP_LABELS} />
{step === 0 && ( {step === 0 && (
<StepAccount <StepAccount
onboard={onboard}
sso={sso} setSso={setSso} email={email} setEmail={setEmail} sso={sso} setSso={setSso} email={email} setEmail={setEmail}
first={first} setFirst={setFirst} last={last} setLast={setLast} first={first} setFirst={setFirst} last={last} setLast={setLast}
cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country} cc={cc} setCc={setCc} phone={phone} setPhone={setPhone} phoneOk={phoneOk} country={country}
@@ -82,7 +141,7 @@ export function RegisterFlow() {
/> />
)} )}
{step === 1 && ( {step === 1 && !onboard && (
<StepVerify <StepVerify
emailValue={sso?.email || email} cc={cc} phone={phone} country={country} emailValue={sso?.email || email} cc={cc} phone={phone} country={country}
emailVerified={emailVerified} setEmailVerified={setEmailVerified} emailVerified={emailVerified} setEmailVerified={setEmailVerified}
@@ -91,8 +150,11 @@ export function RegisterFlow() {
/> />
)} )}
{step === 2 && ( {((onboard && step === 1) || (!onboard && step === 2)) && (
<StepAddress onBack={() => setStep(1)} onFinish={finish} /> <>
{submitErr && <div style={{ marginBottom: 14 }}><FlashNote tone="error">{submitErr}</FlashNote></div>}
<StepAddress sameAs={mailingSame} setSameAs={setMailingSame} onRegAddr={setRegAddr} onMailAddr={setMailAddr} onBack={() => setStep(onboard ? 0 : 1)} onFinish={finish} />
</>
)} )}
</div> </div>
); );
@@ -109,28 +171,19 @@ function StepAccount(p: {
alloeNo: string; setAlloeNo: (v: string) => void; alloeIdOk: boolean; alloeNo: string; setAlloeNo: (v: string) => void; alloeIdOk: boolean;
alloeFirst: string; setAlloeFirst: (v: string) => void; alloeLast: string; setAlloeLast: (v: string) => void; alloeFirst: string; setAlloeFirst: (v: string) => void; alloeLast: string; setAlloeLast: (v: string) => void;
termsOk: boolean; setTermsOk: (v: boolean) => void; privacyOk: boolean; setPrivacyOk: (v: boolean) => void; termsOk: boolean; setTermsOk: (v: boolean) => void; privacyOk: boolean; setPrivacyOk: (v: boolean) => void;
valid: boolean; onContinue: () => void; toLogin: () => void; valid: boolean; onContinue: () => void; toLogin: () => void; onboard?: boolean;
}) { }) {
const [phase, setPhase] = useState<"sso" | "profile">("sso"); // Onboarding (OAuth) starts on the profile step — email is already known + verified.
const [phase, setPhase] = useState<"sso" | "profile">(p.onboard ? "profile" : "sso");
const [modal, setModal] = useState<null | "terms" | "privacy">(null); const [modal, setModal] = useState<null | "terms" | "privacy">(null);
function pickSso(provider: "google" | "microsoft" | "apple") {
if (startSocial(provider)) return;
const mockEmail = `you@${provider === "microsoft" ? "outlook.com" : provider + ".com"}`;
p.setSso({ provider, email: mockEmail });
p.setEmail(mockEmail);
setPhase("profile");
}
if (phase === "sso") { if (phase === "sso") {
const valid = EMAIL_RE.test(p.email); const valid = EMAIL_RE.test(p.email);
return ( return (
<div> <div>
<h1>Create your account</h1> <h1>Create your account</h1>
<p className="sub">Sign up with a provider or your email to get started.</p> <p className="sub">Enter your email to get started.</p>
<div style={{ marginTop: 20 }}> <div style={{ marginTop: 20 }}>
<SocialButtons onPick={pickSso} verb="Sign up" />
<div className="divider">or continue with email</div>
<form onSubmit={(e) => { e.preventDefault(); if (valid) { p.setSso(null); setPhase("profile"); } }}> <form onSubmit={(e) => { e.preventDefault(); if (valid) { p.setSso(null); setPhase("profile"); } }}>
<div className="field"> <div className="field">
<label className="label">Email address</label> <label className="label">Email address</label>
@@ -156,7 +209,7 @@ function StepAccount(p: {
{p.sso ? ( {p.sso ? (
<div className="conn-banner conn-green"><Icon name="check" size={16} /> Connected with {cap(p.sso.provider)} · {p.sso.email}</div> <div className="conn-banner conn-green"><Icon name="check" size={16} /> Connected with {cap(p.sso.provider)} · {p.sso.email}</div>
) : ( ) : (
<div className="conn-banner conn-blue"><Icon name="mail" size={16} /> Creating account for {p.email}</div> <div className="conn-banner conn-blue"><Icon name="mail" size={16} /> {p.onboard ? "Signed in as" : "Creating account for"} {p.email}</div>
)} )}
</div> </div>
@@ -255,7 +308,7 @@ function StepAccount(p: {
{!p.valid && <p className="hint-line">Fill all required fields and accept both documents to continue.</p>} {!p.valid && <p className="hint-line">Fill all required fields and accept both documents to continue.</p>}
<button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid} onClick={p.onContinue}>Create Account &amp; Verify <Icon name="arrowR" size={16} /></button> <button className="btn btn-primary" style={{ marginTop: 14 }} disabled={!p.valid} onClick={p.onContinue}>{p.onboard ? "Continue" : "Create Account & Verify"} <Icon name="arrowR" size={16} /></button>
{modal === "terms" && <LegalModal doc={TERMS} onClose={() => setModal(null)} onReviewed={() => { reviewed.terms = true; setModal(null); }} />} {modal === "terms" && <LegalModal doc={TERMS} onClose={() => setModal(null)} onReviewed={() => { reviewed.terms = true; setModal(null); }} />}
{modal === "privacy" && <LegalModal doc={PRIVACY} onClose={() => setModal(null)} onReviewed={() => { reviewed.privacy = true; setModal(null); }} />} {modal === "privacy" && <LegalModal doc={PRIVACY} onClose={() => setModal(null)} onReviewed={() => { reviewed.privacy = true; setModal(null); }} />}
@@ -370,14 +423,12 @@ function VerifyChannel({ kind, initial, country, cc, initialPhone, verified, onV
<div style={{ marginTop: 12 }}><ResendLink seconds={60} /></div> <div style={{ marginTop: 12 }}><ResendLink seconds={60} /></div>
</div> </div>
)} )}
<p className="demo-hint">Demo: any 6 digits verify; 000000 fails. Email with full/bo shows send errors.</p>
</div> </div>
); );
} }
/* ====================== STEP 3 — ADDRESS ====================== */ /* ====================== STEP 3 — ADDRESS ====================== */
function StepAddress({ onBack, onFinish }: { onBack: () => void; onFinish: () => void }) { function StepAddress({ sameAs, setSameAs, onRegAddr, onMailAddr, onBack, onFinish }: { sameAs: boolean; setSameAs: (v: boolean) => void; onRegAddr: (a: Addr) => void; onMailAddr: (a: Addr) => void; onBack: () => void; onFinish: () => void }) {
const [sameAs, setSameAs] = useState(true);
return ( return (
<div> <div>
<StepBack onClick={onBack} /> <StepBack onClick={onBack} />
@@ -385,7 +436,7 @@ function StepAddress({ onBack, onFinish }: { onBack: () => void; onFinish: () =>
<p className="sub">Add your registered address and where we should send mail.</p> <p className="sub">Add your registered address and where we should send mail.</p>
<AddrSectionHead icon="home" tone="amber" title="Registered address" sub="Where your property is registered" /> <AddrSectionHead icon="home" tone="amber" title="Registered address" sub="Where your property is registered" />
<AddressBlock idPrefix="reg" /> <AddressBlock idPrefix="reg" onChange={onRegAddr} />
<label className="check-row" style={{ marginTop: 16 }}> <label className="check-row" style={{ marginTop: 16 }}>
<input type="checkbox" checked={sameAs} onChange={(e) => setSameAs(e.target.checked)} /> <input type="checkbox" checked={sameAs} onChange={(e) => setSameAs(e.target.checked)} />
@@ -395,7 +446,7 @@ function StepAddress({ onBack, onFinish }: { onBack: () => void; onFinish: () =>
{!sameAs && ( {!sameAs && (
<> <>
<AddrSectionHead icon="mail" tone="blue" title="Mailing / correspondence address" sub="Where we send physical mail" /> <AddrSectionHead icon="mail" tone="blue" title="Mailing / correspondence address" sub="Where we send physical mail" />
<AddressBlock idPrefix="mail" /> <AddressBlock idPrefix="mail" onChange={onMailAddr} />
</> </>
)} )}
@@ -420,7 +471,7 @@ function AddrSectionHead({ icon, title, sub, tone }: { icon: string; title: stri
); );
} }
function AddressBlock({ idPrefix }: { idPrefix: string }) { function AddressBlock({ idPrefix, onChange }: { idPrefix: string; onChange?: (a: Addr) => void }) {
const [country, setCountry] = useState<AddrCountry>(addressCountries[0]); const [country, setCountry] = useState<AddrCountry>(addressCountries[0]);
const [pin, setPin] = useState(""); const [pin, setPin] = useState("");
const [city, setCity] = useState(""); const [city, setCity] = useState("");
@@ -433,6 +484,13 @@ function AddressBlock({ idPrefix }: { idPrefix: string }) {
const [suggestions, setSuggestions] = useState<{ line1: string; line2: string; city: string; state: string; postal: string }[]>([]); const [suggestions, setSuggestions] = useState<{ line1: string; line2: string; city: string; state: string; postal: string }[]>([]);
const debounce = useRef<ReturnType<typeof setTimeout> | null>(null); const debounce = useRef<ReturnType<typeof setTimeout> | null>(null);
// Report the composed address up so the parent can persist it (postalCode is the
// PIN for India, the postal field elsewhere).
useEffect(() => {
onChange?.({ line1, line2, city, state: stateName, postalCode: postal || pin, country: country.code, locality });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [line1, line2, city, stateName, postal, pin, country, locality]);
// India: PIN auto-fill // India: PIN auto-fill
useEffect(() => { useEffect(() => {
if (country.lookup !== "pincode" || pin.length !== 6) return; if (country.lookup !== "pincode" || pin.length !== 6) return;
@@ -542,10 +600,10 @@ function AddressBlock({ idPrefix }: { idPrefix: string }) {
} }
/* ====================== stepper ====================== */ /* ====================== stepper ====================== */
function Stepper({ current }: { current: number }) { function Stepper({ current, labels = STEPS }: { current: number; labels?: string[] }) {
return ( return (
<div className="steps"> <div className="steps">
{STEPS.map((label, i) => { {labels.map((label, i) => {
const done = i < current, on = i === current; const done = i < current, on = i === current;
return ( return (
<div key={label} style={{ display: "contents" }}> <div key={label} style={{ display: "contents" }}>
+11
View File
@@ -0,0 +1,11 @@
// Integration helpers for appshell-sdk. NEXT_PUBLIC_* vars are inlined at build
// time, so this reflects deploy-time configuration.
/**
* True when the Shell (Supabase BFF) is configured. When false, the app falls
* back to the existing mock portal / static user so the demo keeps working before
* the backend is deployed. Gate real-vs-mock auth on this.
*/
export function isShellConfigured(): boolean {
return Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL);
}
+202
View File
@@ -0,0 +1,202 @@
"use client";
// Team-management data layer. Serves EITHER the local mock (when the Shell isn't
// configured — the polished demo keeps working) OR the live be-crm data door
// (crm.team.*), behind one interface so the component is mode-agnostic.
//
// Live contract (be-crm, multi-role):
// query crm.team.member.search { perPage } -> { items: MemberDTO[], meta }
// query crm.team.role.list { includeEmpty } -> { items: RoleDTO[], meta }
// query crm.team.invitation.list { status } -> { items: InvitationDTO[], meta }
// cmd crm.team.member.setRoles { id, roleIds }
// cmd crm.team.member.update { id, jobTitle?, roleIds? }
// cmd crm.team.member.remove { id }
// cmd crm.team.invitation.create/resend/revoke
// cmd crm.team.role.addPermission/removePermission { id, permissionId }
import { useCallback, useMemo, useState } from "react";
import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
import {
members as seedMembers, roles as seedRoles, pendingInvites as seedInvites,
type Member as SeedMember, type Role as SeedRole, type Invite as SeedInvite,
} from "@/components/dashboard/team-data";
/* ---- UI-facing shapes (multi-role; superset of the mock) ---------------- */
export type UiStatus = "active" | "away" | "offline";
export interface UiRole {
id: string; slug: string; name: string; color: string;
description: string; system: boolean; isOwner: boolean;
perms: string[]; attire: string[]; overlap: string; memberCount?: number;
}
export interface UiMember {
id: string; principalId?: string; name: string; initials: string; email: string;
title: string; roleIds: string[]; gradient: string; status: UiStatus;
lastActive: string; deals: number; joined: string; isOwner: boolean; isYou: boolean;
}
export interface UiInvite {
id: string; email: string; roleIds: string[]; invitedBy: string; sentAt: string;
}
export interface TeamData {
live: boolean; loading: boolean; error: string | null;
members: UiMember[]; roles: UiRole[]; invites: UiInvite[];
setMemberRoles: (id: string, roleIds: string[]) => Promise<void>;
updateMember: (id: string, patch: { title?: string; roleIds?: string[] }) => Promise<void>;
removeMember: (id: string) => Promise<void>;
invite: (email: string, roleIds: string[]) => Promise<void>;
resendInvite: (id: string) => Promise<void>;
revokeInvite: (id: string) => Promise<void>;
setPermission: (roleId: string, permId: string, granted: boolean) => Promise<void>;
refetch: () => void;
}
/* ---- helpers ------------------------------------------------------------ */
const GRADIENTS = [
"linear-gradient(135deg,#fda913,#fd6d13)", "linear-gradient(135deg,#6366f1,#8b5cf6)",
"linear-gradient(135deg,#06b6d4,#3b82f6)", "linear-gradient(135deg,#10b981,#059669)",
"linear-gradient(135deg,#f43f5e,#ec4899)", "linear-gradient(135deg,#f59e0b,#ef4444)",
"linear-gradient(135deg,#8b5cf6,#d946ef)", "linear-gradient(135deg,#14b8a6,#0ea5e9)",
];
const gradientFor = (id: string) => GRADIENTS[[...id].reduce((a, c) => a + c.charCodeAt(0), 0) % GRADIENTS.length];
const initialsOf = (name: string) =>
name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
/* ---- be-crm DTO types (subset used here) -------------------------------- */
interface RoleRef { id: string; slug: string; name: string; color: string | null; isSystem: boolean; isOwnerRole: boolean }
interface MemberDTO { id: string; principalId: string; jobTitle: string | null; joinedAt: string; status: "active" | "deactivated"; roles: RoleRef[]; openDeals: number }
interface RoleDTO { id: string; slug: string; name: string; description: string | null; color: string | null; isSystem: boolean; isOwnerRole: boolean; permissions: string[]; memberCount: number }
interface InvitationDTO { id: string; email: string; roles: RoleRef[]; invitedBy: string; createdAt: string }
const relTime = (iso: string): string => {
const then = Date.parse(iso);
if (Number.isNaN(then)) return iso;
return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
};
/* ---- mock → UI ---------------------------------------------------------- */
const mockRole = (r: SeedRole): UiRole => ({
id: r.id, slug: r.id, name: r.name, color: r.color, description: r.description,
system: r.system, isOwner: r.id === "owner", perms: r.perms, attire: r.attire, overlap: r.overlap,
});
const mockMember = (m: SeedMember): UiMember => ({
id: m.id, name: m.name, initials: m.initials, email: m.email, title: m.title,
roleIds: [m.roleId], gradient: m.gradient, status: m.status, lastActive: m.lastActive,
deals: m.deals, joined: m.joined, isOwner: m.roleId === "owner", isYou: m.roleId === "owner",
});
const mockInvite = (i: SeedInvite): UiInvite => ({
id: i.id, email: i.email, roleIds: [i.roleId], invitedBy: i.invitedBy, sentAt: i.sentAt,
});
/* ======================================================================== */
/* Mock implementation (no Shell configured) */
/* ======================================================================== */
function useMockTeam(): TeamData {
const [members, setMembers] = useState<UiMember[]>(() => seedMembers.map(mockMember));
const [roles, setRoles] = useState<UiRole[]>(() => seedRoles.map(mockRole));
const [invites, setInvites] = useState<UiInvite[]>(() => seedInvites.map(mockInvite));
const setMemberRoles = useCallback(async (id: string, roleIds: string[]) => {
setMembers((l) => l.map((m) => (m.id === id ? { ...m, roleIds } : m)));
}, []);
const updateMember = useCallback(async (id: string, patch: { title?: string; roleIds?: string[] }) => {
setMembers((l) => l.map((m) => (m.id === id
? { ...m, ...(patch.title !== undefined ? { title: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}) }
: m)));
}, []);
const removeMember = useCallback(async (id: string) => { setMembers((l) => l.filter((m) => m.id !== id)); }, []);
const invite = useCallback(async (email: string, roleIds: string[]) => {
setInvites((l) => [{ id: `inv_${Date.now()}`, email, roleIds, invitedBy: "James Carter", sentAt: "Just now" }, ...l]);
}, []);
const resendInvite = useCallback(async () => {}, []);
const revokeInvite = useCallback(async (id: string) => { setInvites((l) => l.filter((i) => i.id !== id)); }, []);
const setPermission = useCallback(async (roleId: string, permId: string, granted: boolean) => {
setRoles((l) => l.map((r) => (r.id !== roleId ? r : {
...r, perms: granted ? [...new Set([...r.perms, permId])] : r.perms.filter((p) => p !== permId),
})));
}, []);
return { live: false, loading: false, error: null, members, roles, invites,
setMemberRoles, updateMember, removeMember, invite, resendInvite, revokeInvite, setPermission, refetch: () => {} };
}
/* ======================================================================== */
/* Live implementation (be-crm data door) */
/* ======================================================================== */
function useLiveTeam(): TeamData {
const { sdk } = useAppShell();
const { user } = useAuth();
const meId = user?.id;
const membersQ = useQuery<{ items: MemberDTO[] }>("crm.team.member.search", { perPage: 100 });
const rolesQ = useQuery<{ items: RoleDTO[] }>("crm.team.role.list", { includeEmpty: true });
const invitesQ = useQuery<{ items: InvitationDTO[] }>("crm.team.invitation.list", { status: "pending" });
const refetch = useCallback(() => { membersQ.refetch(); rolesQ.refetch(); invitesQ.refetch(); }, [membersQ, rolesQ, invitesQ]);
const cmd = useCallback(async (action: string, variables: Record<string, unknown>) => {
await sdk.command(action, variables);
refetch();
}, [sdk, refetch]);
const roles: UiRole[] = useMemo(() => (rolesQ.data?.items ?? []).map((r) => ({
id: r.id, slug: r.slug, name: r.name, color: r.color ?? "var(--text-2)",
description: r.description ?? "", system: r.isSystem, isOwner: r.isOwnerRole,
perms: r.permissions, attire: [], overlap: "", memberCount: r.memberCount,
})), [rolesQ.data]);
const members: UiMember[] = useMemo(() => (membersQ.data?.items ?? []).map((m) => {
const isYou = !!meId && m.principalId === meId;
const name = isYou && user?.displayName
? user.displayName
: (m.jobTitle?.trim() || `Member ${m.principalId.replace(/^pp_/, "").slice(0, 6)}`);
return {
id: m.id, principalId: m.principalId, name, initials: initialsOf(name),
email: isYou && user?.email ? user.email : m.principalId,
title: m.jobTitle ?? "", roleIds: m.roles.map((r) => r.id), gradient: gradientFor(m.id),
status: (m.status === "active" ? "active" : "offline") as UiStatus,
lastActive: m.status === "active" ? "Active" : "—",
deals: m.openDeals, joined: relTime(m.joinedAt),
isOwner: m.roles.some((r) => r.isOwnerRole), isYou,
};
}), [membersQ.data, meId, user]);
const invites: UiInvite[] = useMemo(() => (invitesQ.data?.items ?? []).map((i) => ({
id: i.id, email: i.email, roleIds: i.roles.map((r) => r.id),
invitedBy: i.invitedBy, sentAt: relTime(i.createdAt),
})), [invitesQ.data]);
return {
live: true,
loading: membersQ.loading || rolesQ.loading || invitesQ.loading,
error: (membersQ.error ?? rolesQ.error ?? invitesQ.error)?.message ?? null,
members, roles, invites,
setMemberRoles: (id, roleIds) => cmd("crm.team.member.setRoles", { id, roleIds }),
updateMember: (id, patch) => cmd("crm.team.member.update", {
id, ...(patch.title !== undefined ? { jobTitle: patch.title } : {}), ...(patch.roleIds ? { roleIds: patch.roleIds } : {}),
}),
removeMember: (id) => cmd("crm.team.member.remove", { id }),
invite: (email, roleIds) => cmd("crm.team.invitation.create", { email, roleIds }),
resendInvite: (id) => cmd("crm.team.invitation.resend", { id }),
revokeInvite: (id) => cmd("crm.team.invitation.revoke", { id }),
setPermission: (roleId, permId, granted) =>
cmd(granted ? "crm.team.role.addPermission" : "crm.team.role.removePermission", { id: roleId, permissionId: permId }),
refetch,
};
}
/* ---- public hook: pick the implementation at module-config time --------- */
const SHELL = isShellConfigured();
export function useTeamData(): TeamData {
// `SHELL` is constant for the life of the bundle (NEXT_PUBLIC_* is build-time),
// so the same hook path runs every render — Rules-of-Hooks safe.
return SHELL ? useLiveTeam() : useMockTeam();
}