create-ab-app 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +60 -0
  2. package/index.js +380 -0
  3. package/package.json +36 -0
  4. package/template/.env.example +8 -0
  5. package/template/.oxlintrc.json +8 -0
  6. package/template/README.md +196 -0
  7. package/template/_gitignore +24 -0
  8. package/template/components.json +25 -0
  9. package/template/index.html +13 -0
  10. package/template/package-lock.json +6096 -0
  11. package/template/package.json +40 -0
  12. package/template/public/_redirects +1 -0
  13. package/template/public/favicon.svg +1 -0
  14. package/template/public/icons.svg +24 -0
  15. package/template/src/App.tsx +60 -0
  16. package/template/src/assets/hero.png +0 -0
  17. package/template/src/assets/vite.svg +1 -0
  18. package/template/src/components/app-sidebar.tsx +53 -0
  19. package/template/src/components/credit-balance.tsx +31 -0
  20. package/template/src/components/docs/doc-primitives.tsx +125 -0
  21. package/template/src/components/full-page-loader.tsx +14 -0
  22. package/template/src/components/header-user.tsx +64 -0
  23. package/template/src/components/layouts/app-layout.tsx +80 -0
  24. package/template/src/components/missing-env.tsx +24 -0
  25. package/template/src/components/nav-main.tsx +87 -0
  26. package/template/src/components/nav-user.tsx +69 -0
  27. package/template/src/components/theme-toggle.tsx +39 -0
  28. package/template/src/components/ui/avatar.tsx +106 -0
  29. package/template/src/components/ui/breadcrumb.tsx +124 -0
  30. package/template/src/components/ui/button.tsx +57 -0
  31. package/template/src/components/ui/card.tsx +102 -0
  32. package/template/src/components/ui/collapsible.tsx +19 -0
  33. package/template/src/components/ui/dropdown-menu.tsx +267 -0
  34. package/template/src/components/ui/field.tsx +238 -0
  35. package/template/src/components/ui/input.tsx +19 -0
  36. package/template/src/components/ui/label.tsx +19 -0
  37. package/template/src/components/ui/separator.tsx +22 -0
  38. package/template/src/components/ui/sheet.tsx +136 -0
  39. package/template/src/components/ui/sidebar.tsx +721 -0
  40. package/template/src/components/ui/skeleton.tsx +13 -0
  41. package/template/src/components/ui/sonner.tsx +47 -0
  42. package/template/src/components/ui/tooltip.tsx +65 -0
  43. package/template/src/components/user-menu.tsx +96 -0
  44. package/template/src/config/env.ts +14 -0
  45. package/template/src/config/navigation.ts +52 -0
  46. package/template/src/hooks/use-mobile.ts +19 -0
  47. package/template/src/hooks/use-user-identity.ts +27 -0
  48. package/template/src/index.css +134 -0
  49. package/template/src/lib/http.ts +40 -0
  50. package/template/src/lib/sso/account-settings.tsx +53 -0
  51. package/template/src/lib/sso/auth-context.ts +16 -0
  52. package/template/src/lib/sso/auth-provider.tsx +42 -0
  53. package/template/src/lib/sso/auth-screens.tsx +152 -0
  54. package/template/src/lib/sso/config.ts +13 -0
  55. package/template/src/lib/sso/entitlements.ts +101 -0
  56. package/template/src/lib/sso/index.ts +33 -0
  57. package/template/src/lib/sso/protected-route.tsx +72 -0
  58. package/template/src/lib/sso/queries.ts +55 -0
  59. package/template/src/lib/sso/require-plan.tsx +19 -0
  60. package/template/src/lib/sso/token-store.ts +62 -0
  61. package/template/src/lib/utils.ts +1 -0
  62. package/template/src/main.tsx +42 -0
  63. package/template/src/routes/app/example.tsx +125 -0
  64. package/template/src/routes/auth/forgot-password.tsx +5 -0
  65. package/template/src/routes/auth/login.tsx +5 -0
  66. package/template/src/routes/auth/signup.tsx +5 -0
  67. package/template/src/routes/auth/verify-email.tsx +5 -0
  68. package/template/src/routes/docs/api.tsx +66 -0
  69. package/template/src/routes/docs/auth.tsx +145 -0
  70. package/template/src/routes/docs/billing.tsx +103 -0
  71. package/template/src/routes/docs/configuration.tsx +113 -0
  72. package/template/src/routes/docs/overview.tsx +97 -0
  73. package/template/src/routes/docs/routing.tsx +126 -0
  74. package/template/src/routes/docs/structure.tsx +150 -0
  75. package/template/src/routes/docs/theming.tsx +88 -0
  76. package/template/src/routes/not-found.tsx +12 -0
  77. package/template/src/styles/aas-theme.css +21 -0
  78. package/template/src/vite-env.d.ts +12 -0
  79. package/template/tsconfig.app.json +30 -0
  80. package/template/tsconfig.json +12 -0
  81. package/template/tsconfig.node.json +23 -0
  82. package/template/vercel.json +3 -0
  83. package/template/vite.config.ts +17 -0
  84. package/variants/minimal/src/App.tsx +43 -0
  85. package/variants/minimal/src/components/app-sidebar.tsx +50 -0
  86. package/variants/minimal/src/config/navigation.ts +19 -0
  87. package/variants/minimal/src/routes/app/home.tsx +45 -0
@@ -0,0 +1,13 @@
1
+ import { cn } from "cn"
2
+
3
+ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4
+ return (
5
+ <div
6
+ data-slot="skeleton"
7
+ className={cn("animate-pulse rounded-md bg-muted", className)}
8
+ {...props}
9
+ />
10
+ )
11
+ }
12
+
13
+ export { Skeleton }
@@ -0,0 +1,47 @@
1
+ import { useTheme } from "next-themes"
2
+ import { Toaster as Sonner, type ToasterProps } from "sonner"
3
+ import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
4
+
5
+ const Toaster = ({ ...props }: ToasterProps) => {
6
+ const { theme = "system" } = useTheme()
7
+
8
+ return (
9
+ <Sonner
10
+ theme={theme as ToasterProps["theme"]}
11
+ className="toaster group"
12
+ icons={{
13
+ success: (
14
+ <CircleCheckIcon className="size-4" />
15
+ ),
16
+ info: (
17
+ <InfoIcon className="size-4" />
18
+ ),
19
+ warning: (
20
+ <TriangleAlertIcon className="size-4" />
21
+ ),
22
+ error: (
23
+ <OctagonXIcon className="size-4" />
24
+ ),
25
+ loading: (
26
+ <Loader2Icon className="size-4 animate-spin" />
27
+ ),
28
+ }}
29
+ style={
30
+ {
31
+ "--normal-bg": "var(--popover)",
32
+ "--normal-text": "var(--popover-foreground)",
33
+ "--normal-border": "var(--border)",
34
+ "--border-radius": "var(--radius)",
35
+ } as React.CSSProperties
36
+ }
37
+ toastOptions={{
38
+ classNames: {
39
+ toast: "cn-toast",
40
+ },
41
+ }}
42
+ {...props}
43
+ />
44
+ )
45
+ }
46
+
47
+ export { Toaster }
@@ -0,0 +1,65 @@
1
+ "use client"
2
+
3
+ import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
4
+ import { cn } from "cn"
5
+
6
+ function TooltipProvider({
7
+ delay = 0,
8
+ ...props
9
+ }: TooltipPrimitive.Provider.Props) {
10
+ return (
11
+ <TooltipPrimitive.Provider
12
+ data-slot="tooltip-provider"
13
+ delay={delay}
14
+ {...props}
15
+ />
16
+ )
17
+ }
18
+
19
+ function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
20
+ return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
21
+ }
22
+
23
+ function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
24
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
25
+ }
26
+
27
+ function TooltipContent({
28
+ className,
29
+ side = "top",
30
+ sideOffset = 4,
31
+ align = "center",
32
+ alignOffset = 0,
33
+ children,
34
+ ...props
35
+ }: TooltipPrimitive.Popup.Props &
36
+ Pick<
37
+ TooltipPrimitive.Positioner.Props,
38
+ "align" | "alignOffset" | "side" | "sideOffset"
39
+ >) {
40
+ return (
41
+ <TooltipPrimitive.Portal>
42
+ <TooltipPrimitive.Positioner
43
+ align={align}
44
+ alignOffset={alignOffset}
45
+ side={side}
46
+ sideOffset={sideOffset}
47
+ className="isolate z-50"
48
+ >
49
+ <TooltipPrimitive.Popup
50
+ data-slot="tooltip-content"
51
+ className={cn(
52
+ "z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
53
+ className
54
+ )}
55
+ {...props}
56
+ >
57
+ {children}
58
+ <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
59
+ </TooltipPrimitive.Popup>
60
+ </TooltipPrimitive.Positioner>
61
+ </TooltipPrimitive.Portal>
62
+ )
63
+ }
64
+
65
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -0,0 +1,96 @@
1
+ import { useNavigate } from "react-router-dom"
2
+ import {
3
+ BadgeCheckIcon,
4
+ CoinsIcon,
5
+ CreditCardIcon,
6
+ LogOutIcon,
7
+ SparklesIcon,
8
+ WalletIcon,
9
+ } from "lucide-react"
10
+
11
+ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
12
+ import {
13
+ DropdownMenuGroup,
14
+ DropdownMenuItem,
15
+ DropdownMenuSeparator,
16
+ } from "@/components/ui/dropdown-menu"
17
+ import { useUserIdentity } from "@/hooks/use-user-identity"
18
+ import { useAccountSettings, useAuth, useEntitlements } from "@/lib/sso"
19
+
20
+ export function UserAvatar({ className }: { className?: string }) {
21
+ const { name, avatarUrl, initials: fallback } = useUserIdentity()
22
+ return (
23
+ <Avatar className={className}>
24
+ <AvatarImage src={avatarUrl} alt={name} />
25
+ <AvatarFallback>{fallback}</AvatarFallback>
26
+ </Avatar>
27
+ )
28
+ }
29
+
30
+ /** Avatar plus the name/email block, as used in a menu label or a trigger. */
31
+ export function UserIdentity() {
32
+ const { name, email } = useUserIdentity()
33
+ return (
34
+ <>
35
+ <UserAvatar />
36
+ <div className="grid flex-1 text-left text-sm leading-tight">
37
+ <span className="truncate font-medium">{name}</span>
38
+ <span className="truncate text-xs">{email}</span>
39
+ </div>
40
+ </>
41
+ )
42
+ }
43
+
44
+ /** Each item opens Account Settings on its section — the billing entry point. */
45
+ export function UserMenuItems() {
46
+ const { isActive } = useEntitlements()
47
+ const { open } = useAccountSettings()
48
+ const { signOut } = useAuth()
49
+ const navigate = useNavigate()
50
+
51
+ return (
52
+ <>
53
+ {!isActive && (
54
+ <>
55
+ <DropdownMenuSeparator />
56
+ <DropdownMenuGroup>
57
+ <DropdownMenuItem onClick={() => open("plans")}>
58
+ <SparklesIcon />
59
+ Upgrade
60
+ </DropdownMenuItem>
61
+ </DropdownMenuGroup>
62
+ </>
63
+ )}
64
+
65
+ <DropdownMenuSeparator />
66
+ <DropdownMenuGroup>
67
+ <DropdownMenuItem onClick={() => open("account")}>
68
+ <BadgeCheckIcon />
69
+ Account
70
+ </DropdownMenuItem>
71
+ <DropdownMenuItem onClick={() => open("plans")}>
72
+ <CreditCardIcon />
73
+ Plans &amp; add-ons
74
+ </DropdownMenuItem>
75
+ <DropdownMenuItem onClick={() => open("wallet")}>
76
+ <WalletIcon />
77
+ Wallet &amp; balance
78
+ </DropdownMenuItem>
79
+ <DropdownMenuItem onClick={() => open("credits")}>
80
+ <CoinsIcon />
81
+ Credits
82
+ </DropdownMenuItem>
83
+ </DropdownMenuGroup>
84
+
85
+ <DropdownMenuSeparator />
86
+ <DropdownMenuItem
87
+ onClick={() => {
88
+ void signOut().then(() => navigate("/login", { replace: true }))
89
+ }}
90
+ >
91
+ <LogOutIcon />
92
+ Log out
93
+ </DropdownMenuItem>
94
+ </>
95
+ )
96
+ }
@@ -0,0 +1,14 @@
1
+ export const env = {
2
+ authProjectId: import.meta.env.VITE_AUTH_PROJECT_ID ?? "",
3
+ authApiBaseUrl: import.meta.env.VITE_AUTH_API_BASE_URL || undefined,
4
+ /** Your own product API — see src/lib/http.ts. */
5
+ apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? "",
6
+ appName: import.meta.env.VITE_APP_NAME || "Acme Inc",
7
+ } as const
8
+
9
+ /** Env vars the app cannot start without. */
10
+ export function missingRequiredEnv(): string[] {
11
+ const missing: string[] = []
12
+ if (!env.authProjectId) missing.push("VITE_AUTH_PROJECT_ID")
13
+ return missing
14
+ }
@@ -0,0 +1,52 @@
1
+ import {
2
+ BookOpenIcon,
3
+ CreditCardIcon,
4
+ FolderTreeIcon,
5
+ KeyRoundIcon,
6
+ PaletteIcon,
7
+ PlugIcon,
8
+ RouteIcon,
9
+ SettingsIcon,
10
+ SparklesIcon,
11
+ } from "lucide-react"
12
+
13
+ /** Anything that renders like a Lucide icon. */
14
+ export type IconComponent = React.ComponentType<{ className?: string }>
15
+
16
+ export type NavItem = {
17
+ title: string
18
+ url: string
19
+ icon?: IconComponent
20
+ /** Item ids that unlock this entry. Omit to show it to every signed-in user. */
21
+ requiresIds?: string[]
22
+ items?: { title: string; url: string; requiresIds?: string[] }[]
23
+ }
24
+
25
+ /**
26
+ * The sidebar, as data. Replace these groups with your product's nav — the docs
27
+ * pages under src/routes/docs can go with them.
28
+ */
29
+ export const navGettingStarted: NavItem[] = [
30
+ { title: "Overview", url: "/", icon: BookOpenIcon },
31
+ { title: "Project structure", url: "/docs/structure", icon: FolderTreeIcon },
32
+ { title: "Configuration", url: "/docs/configuration", icon: SettingsIcon },
33
+ ]
34
+
35
+ export const navGuides: NavItem[] = [
36
+ { title: "Auth", url: "/docs/auth", icon: KeyRoundIcon },
37
+ { title: "Billing & plans", url: "/docs/billing", icon: CreditCardIcon },
38
+ { title: "Routing & guards", url: "/docs/routing", icon: RouteIcon },
39
+ { title: "UI & theming", url: "/docs/theming", icon: PaletteIcon },
40
+ { title: "Your own API", url: "/docs/api", icon: PlugIcon },
41
+ ]
42
+
43
+ export const navExamples: NavItem[] = [
44
+ { title: "Live example", url: "/example", icon: SparklesIcon },
45
+ ]
46
+
47
+ /** Every nav entry, flattened — used for breadcrumb titles. */
48
+ export const allNavItems: NavItem[] = [
49
+ ...navGettingStarted,
50
+ ...navGuides,
51
+ ...navExamples,
52
+ ]
@@ -0,0 +1,19 @@
1
+ import * as React from "react"
2
+
3
+ const MOBILE_BREAKPOINT = 768
4
+
5
+ export function useIsMobile() {
6
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
7
+
8
+ React.useEffect(() => {
9
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10
+ const onChange = () => {
11
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12
+ }
13
+ mql.addEventListener("change", onChange)
14
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15
+ return () => mql.removeEventListener("change", onChange)
16
+ }, [])
17
+
18
+ return !!isMobile
19
+ }
@@ -0,0 +1,27 @@
1
+ import type { MappedProfile } from "@/lib/sso"
2
+ import { useProfile } from "@/lib/sso"
3
+
4
+ function displayName(profile?: MappedProfile) {
5
+ if (!profile) return ""
6
+ const full = [profile.firstName, profile.lastName].filter(Boolean).join(" ")
7
+ return full || profile.username || profile.email
8
+ }
9
+
10
+ function initials(name: string) {
11
+ const parts = name.trim().split(/\s+/).filter(Boolean)
12
+ if (parts.length === 0) return "?"
13
+ return (parts[0][0] + (parts[1]?.[0] ?? "")).toUpperCase()
14
+ }
15
+
16
+ /** The display fields the sidebar and header menus both need. */
17
+ export function useUserIdentity() {
18
+ const { data: profile, isPending } = useProfile()
19
+ const name = displayName(profile)
20
+ return {
21
+ isPending,
22
+ name,
23
+ email: profile?.email ?? profile?.username ?? "",
24
+ avatarUrl: profile?.avatarUrl,
25
+ initials: initials(name),
26
+ }
27
+ }
@@ -0,0 +1,134 @@
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+ @import "shadcn/tailwind.css";
4
+ @import "@fontsource-variable/geist";
5
+
6
+ /* SDK styles — must come after Tailwind. */
7
+ @import "ab-ecosystem-sso/styles.css";
8
+ @import "./styles/aas-theme.css";
9
+
10
+ @custom-variant dark (&:is(.dark *));
11
+
12
+ @theme inline {
13
+ --font-heading: var(--font-sans);
14
+ --font-sans: 'Geist Variable', sans-serif;
15
+ --color-sidebar-ring: var(--sidebar-ring);
16
+ --color-sidebar-border: var(--sidebar-border);
17
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
18
+ --color-sidebar-accent: var(--sidebar-accent);
19
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
20
+ --color-sidebar-primary: var(--sidebar-primary);
21
+ --color-sidebar-foreground: var(--sidebar-foreground);
22
+ --color-sidebar: var(--sidebar);
23
+ --color-chart-5: var(--chart-5);
24
+ --color-chart-4: var(--chart-4);
25
+ --color-chart-3: var(--chart-3);
26
+ --color-chart-2: var(--chart-2);
27
+ --color-chart-1: var(--chart-1);
28
+ --color-ring: var(--ring);
29
+ --color-input: var(--input);
30
+ --color-border: var(--border);
31
+ --color-destructive: var(--destructive);
32
+ --color-accent-foreground: var(--accent-foreground);
33
+ --color-accent: var(--accent);
34
+ --color-muted-foreground: var(--muted-foreground);
35
+ --color-muted: var(--muted);
36
+ --color-secondary-foreground: var(--secondary-foreground);
37
+ --color-secondary: var(--secondary);
38
+ --color-primary-foreground: var(--primary-foreground);
39
+ --color-primary: var(--primary);
40
+ --color-popover-foreground: var(--popover-foreground);
41
+ --color-popover: var(--popover);
42
+ --color-card-foreground: var(--card-foreground);
43
+ --color-card: var(--card);
44
+ --color-foreground: var(--foreground);
45
+ --color-background: var(--background);
46
+ --radius-sm: calc(var(--radius) * 0.6);
47
+ --radius-md: calc(var(--radius) * 0.8);
48
+ --radius-lg: var(--radius);
49
+ --radius-xl: calc(var(--radius) * 1.4);
50
+ --radius-2xl: calc(var(--radius) * 1.8);
51
+ --radius-3xl: calc(var(--radius) * 2.2);
52
+ --radius-4xl: calc(var(--radius) * 2.6);
53
+ }
54
+
55
+ :root {
56
+ --background: oklch(1 0 0);
57
+ --foreground: oklch(0.145 0 0);
58
+ --card: oklch(1 0 0);
59
+ --card-foreground: oklch(0.145 0 0);
60
+ --popover: oklch(1 0 0);
61
+ --popover-foreground: oklch(0.145 0 0);
62
+ --primary: oklch(0.205 0 0);
63
+ --primary-foreground: oklch(0.985 0 0);
64
+ --secondary: oklch(0.97 0 0);
65
+ --secondary-foreground: oklch(0.205 0 0);
66
+ --muted: oklch(0.97 0 0);
67
+ --muted-foreground: oklch(0.556 0 0);
68
+ --accent: oklch(0.97 0 0);
69
+ --accent-foreground: oklch(0.205 0 0);
70
+ --destructive: oklch(0.577 0.245 27.325);
71
+ --border: oklch(0.922 0 0);
72
+ --input: oklch(0.922 0 0);
73
+ --ring: oklch(0.708 0 0);
74
+ --chart-1: oklch(0.87 0 0);
75
+ --chart-2: oklch(0.556 0 0);
76
+ --chart-3: oklch(0.439 0 0);
77
+ --chart-4: oklch(0.371 0 0);
78
+ --chart-5: oklch(0.269 0 0);
79
+ --radius: 0.625rem;
80
+ --sidebar: oklch(0.985 0 0);
81
+ --sidebar-foreground: oklch(0.145 0 0);
82
+ --sidebar-primary: oklch(0.205 0 0);
83
+ --sidebar-primary-foreground: oklch(0.985 0 0);
84
+ --sidebar-accent: oklch(0.97 0 0);
85
+ --sidebar-accent-foreground: oklch(0.205 0 0);
86
+ --sidebar-border: oklch(0.922 0 0);
87
+ --sidebar-ring: oklch(0.708 0 0);
88
+ }
89
+
90
+ .dark {
91
+ --background: oklch(0.145 0 0);
92
+ --foreground: oklch(0.985 0 0);
93
+ --card: oklch(0.205 0 0);
94
+ --card-foreground: oklch(0.985 0 0);
95
+ --popover: oklch(0.205 0 0);
96
+ --popover-foreground: oklch(0.985 0 0);
97
+ --primary: oklch(0.922 0 0);
98
+ --primary-foreground: oklch(0.205 0 0);
99
+ --secondary: oklch(0.269 0 0);
100
+ --secondary-foreground: oklch(0.985 0 0);
101
+ --muted: oklch(0.269 0 0);
102
+ --muted-foreground: oklch(0.708 0 0);
103
+ --accent: oklch(0.269 0 0);
104
+ --accent-foreground: oklch(0.985 0 0);
105
+ --destructive: oklch(0.704 0.191 22.216);
106
+ --border: oklch(1 0 0 / 10%);
107
+ --input: oklch(1 0 0 / 15%);
108
+ --ring: oklch(0.556 0 0);
109
+ --chart-1: oklch(0.87 0 0);
110
+ --chart-2: oklch(0.556 0 0);
111
+ --chart-3: oklch(0.439 0 0);
112
+ --chart-4: oklch(0.371 0 0);
113
+ --chart-5: oklch(0.269 0 0);
114
+ --sidebar: oklch(0.205 0 0);
115
+ --sidebar-foreground: oklch(0.985 0 0);
116
+ --sidebar-primary: oklch(0.488 0.243 264.376);
117
+ --sidebar-primary-foreground: oklch(0.985 0 0);
118
+ --sidebar-accent: oklch(0.269 0 0);
119
+ --sidebar-accent-foreground: oklch(0.985 0 0);
120
+ --sidebar-border: oklch(1 0 0 / 10%);
121
+ --sidebar-ring: oklch(0.556 0 0);
122
+ }
123
+
124
+ @layer base {
125
+ * {
126
+ @apply border-border outline-ring/50;
127
+ }
128
+ body {
129
+ @apply bg-background text-foreground;
130
+ }
131
+ html {
132
+ @apply font-sans;
133
+ }
134
+ }
@@ -0,0 +1,40 @@
1
+ import { env } from "@/config/env"
2
+ import { tokenStore } from "@/lib/sso"
3
+
4
+ export class HttpError extends Error {
5
+ readonly status: number
6
+ readonly body: unknown
7
+
8
+ constructor(status: number, body: unknown, message: string) {
9
+ super(message)
10
+ this.name = "HttpError"
11
+ this.status = status
12
+ this.body = body
13
+ }
14
+ }
15
+
16
+ /** Client for your product API. Attaches the bearer; a 401 drops the token. */
17
+ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
18
+ const token = tokenStore.get()
19
+ const headers = new Headers(init.headers)
20
+ headers.set("Accept", "application/json")
21
+ if (init.body && !headers.has("Content-Type")) {
22
+ headers.set("Content-Type", "application/json")
23
+ }
24
+ if (token) headers.set("Authorization", `Bearer ${token}`)
25
+
26
+ const response = await fetch(`${env.apiBaseUrl}${path}`, { ...init, headers })
27
+
28
+ if (response.status === 401) {
29
+ tokenStore.clear()
30
+ throw new HttpError(401, null, "Session expired")
31
+ }
32
+
33
+ const isJson = response.headers.get("Content-Type")?.includes("application/json")
34
+ const body = isJson ? await response.json() : await response.text()
35
+
36
+ if (!response.ok) {
37
+ throw new HttpError(response.status, body, `Request failed: ${response.status}`)
38
+ }
39
+ return body as T
40
+ }
@@ -0,0 +1,53 @@
1
+ import { useCallback, useEffect } from "react"
2
+ import { useNavigate } from "react-router-dom"
3
+ import { useQueryClient } from "@tanstack/react-query"
4
+ import {
5
+ openAccountSettings,
6
+ restoreAccountSettings,
7
+ type AccountSettingsSection,
8
+ } from "ab-ecosystem-sso"
9
+
10
+ import { useAuth } from "./auth-context"
11
+ import { ssoProps } from "./config"
12
+ import { ssoKeys } from "./queries"
13
+
14
+ /** The billing surface: plans, add-ons, wallet and credits all live in this modal. */
15
+ export function useAccountSettings() {
16
+ const { token, signOut } = useAuth()
17
+ const navigate = useNavigate()
18
+ const queryClient = useQueryClient()
19
+
20
+ const invalidate = useCallback(() => {
21
+ void queryClient.invalidateQueries({ queryKey: ssoKeys.all })
22
+ }, [queryClient])
23
+
24
+ const open = useCallback(
25
+ (initialSection: AccountSettingsSection = "general") =>
26
+ openAccountSettings({
27
+ ...ssoProps,
28
+ token: token ?? undefined,
29
+ initialSection,
30
+ // The modal does its own API calls; these just refresh our cached copies.
31
+ onChangePlan: invalidate,
32
+ onAddFunds: invalidate,
33
+ onSaveProfile: invalidate,
34
+ onLogout: () => {
35
+ void signOut().then(() => navigate("/login", { replace: true }))
36
+ },
37
+ }),
38
+ [token, invalidate, signOut, navigate],
39
+ )
40
+
41
+ return { open, invalidate }
42
+ }
43
+
44
+ /** Re-opens the modal after a refresh when a #settings-* hash is present. */
45
+ export function useRestoreAccountSettings() {
46
+ const { token } = useAuth()
47
+
48
+ useEffect(() => {
49
+ if (!token) return
50
+ const handle = restoreAccountSettings({ ...ssoProps, token })
51
+ return () => handle?.close()
52
+ }, [token])
53
+ }
@@ -0,0 +1,16 @@
1
+ import { createContext, use } from "react"
2
+
3
+ export type AuthContextValue = {
4
+ token: string | null
5
+ isAuthenticated: boolean
6
+ signIn: (token: string) => void
7
+ signOut: () => Promise<void>
8
+ }
9
+
10
+ export const AuthContext = createContext<AuthContextValue | null>(null)
11
+
12
+ export function useAuth(): AuthContextValue {
13
+ const context = use(AuthContext)
14
+ if (!context) throw new Error("useAuth must be used inside <AuthProvider>")
15
+ return context
16
+ }
@@ -0,0 +1,42 @@
1
+ import { useCallback, useMemo, useSyncExternalStore } from "react"
2
+ import { useQueryClient } from "@tanstack/react-query"
3
+ import { logoutApi } from "ab-ecosystem-sso"
4
+
5
+ import { AuthContext, type AuthContextValue } from "./auth-context"
6
+ import { ssoConfig } from "./config"
7
+ import { tokenStore } from "./token-store"
8
+
9
+ /** Owns the token app-wide. The SDK's useSession() returns neither token nor user. */
10
+ export function AuthProvider({ children }: { children: React.ReactNode }) {
11
+ const queryClient = useQueryClient()
12
+ const token = useSyncExternalStore(
13
+ tokenStore.subscribe,
14
+ tokenStore.get,
15
+ () => null,
16
+ )
17
+
18
+ const signIn = useCallback((next: string) => {
19
+ tokenStore.set(next)
20
+ }, [])
21
+
22
+ const signOut = useCallback(async () => {
23
+ const current = tokenStore.get()
24
+ if (current) {
25
+ // Sign out locally even if the network call fails.
26
+ try {
27
+ await logoutApi(ssoConfig.projectId, current, ssoConfig.apiBaseUrl)
28
+ } catch {
29
+ /* empty */
30
+ }
31
+ }
32
+ tokenStore.clear()
33
+ queryClient.clear()
34
+ }, [queryClient])
35
+
36
+ const value = useMemo<AuthContextValue>(
37
+ () => ({ token, isAuthenticated: Boolean(token), signIn, signOut }),
38
+ [token, signIn, signOut],
39
+ )
40
+
41
+ return <AuthContext value={value}>{children}</AuthContext>
42
+ }