create-lego-one 2.0.10 → 2.0.13
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.
- package/dist/index.cjs +179 -0
- package/dist/index.cjs.map +1 -1
- package/package.json +5 -3
- package/template/.cursor/rules/rules.mdc +639 -0
- package/template/.dockerignore +58 -0
- package/template/.env.example +18 -0
- package/template/.eslintignore +5 -0
- package/template/.eslintrc.js +28 -0
- package/template/.prettierignore +6 -0
- package/template/.prettierrc +11 -0
- package/template/CLAUDE.md +634 -0
- package/template/Dockerfile +67 -0
- package/template/PROMPT.md +457 -0
- package/template/README.md +325 -0
- package/template/docker-compose.yml +48 -0
- package/template/docker-entrypoint.sh +23 -0
- package/template/docs/checkpoints/.template.md +64 -0
- package/template/docs/checkpoints/framework/01-infrastructure-setup.md +132 -0
- package/template/docs/checkpoints/framework/02-pocketbase-setup.md +155 -0
- package/template/docs/checkpoints/framework/03-host-kernel.md +170 -0
- package/template/docs/checkpoints/framework/04-auth-system.md +163 -0
- package/template/docs/checkpoints/framework/phase-05-multitenancy-rbac.md +223 -0
- package/template/docs/checkpoints/framework/phase-06-ui-components.md +260 -0
- package/template/docs/checkpoints/framework/phase-07-communication-system.md +276 -0
- package/template/docs/checkpoints/framework/phase-08-plugin-system.md +91 -0
- package/template/docs/checkpoints/framework/phase-09-dashboard-plugin.md +111 -0
- package/template/docs/checkpoints/framework/phase-10-todo-plugin.md +169 -0
- package/template/docs/checkpoints/framework/phase-11-testing.md +264 -0
- package/template/docs/checkpoints/framework/phase-12-deployment.md +294 -0
- package/template/docs/checkpoints/framework/phase-13-documentation.md +312 -0
- package/template/docs/framework/plans/00-index.md +164 -0
- package/template/docs/framework/plans/01-infrastructure-setup.md +855 -0
- package/template/docs/framework/plans/02-pocketbase-setup.md +1374 -0
- package/template/docs/framework/plans/03-host-kernel.md +1518 -0
- package/template/docs/framework/plans/04-auth-system.md +1466 -0
- package/template/docs/framework/plans/05-multitenancy-rbac.md +1527 -0
- package/template/docs/framework/plans/06-ui-components.md +1478 -0
- package/template/docs/framework/plans/07-communication-system.md +1106 -0
- package/template/docs/framework/plans/08-plugin-system.md +1179 -0
- package/template/docs/framework/plans/09-dashboard-plugin.md +1137 -0
- package/template/docs/framework/plans/10-todo-plugin.md +1343 -0
- package/template/docs/framework/plans/11-testing.md +935 -0
- package/template/docs/framework/plans/12-deployment.md +896 -0
- package/template/docs/framework/prompts/0-boilerplate-modernjs.md +151 -0
- package/template/docs/framework/research/00-modernjs-audit.md +488 -0
- package/template/docs/framework/research/01-system-blueprint.md +721 -0
- package/template/docs/framework/research/02-data-migration-protocol.md +699 -0
- package/template/docs/framework/research/03-host-setup.md +714 -0
- package/template/docs/framework/research/04-plugin-architecture.md +645 -0
- package/template/docs/framework/research/05-slot-injection-pattern.md +671 -0
- package/template/docs/framework/research/06-cli-strategy.md +615 -0
- package/template/docs/framework/research/07-deployment.md +629 -0
- package/template/docs/framework/research/README.md +282 -0
- package/template/docs/framework/setup/00-index.md +210 -0
- package/template/docs/framework/setup/01-framework-structure.md +308 -0
- package/template/docs/framework/setup/02-development-workflow.md +405 -0
- package/template/docs/framework/setup/03-environment-setup.md +215 -0
- package/template/docs/framework/setup/04-kernel-architecture.md +499 -0
- package/template/docs/framework/setup/05-plugin-system.md +620 -0
- package/template/docs/framework/setup/06-communication-patterns.md +451 -0
- package/template/docs/framework/setup/07-plugin-development.md +582 -0
- package/template/docs/framework/setup/08-component-library.md +658 -0
- package/template/docs/framework/setup/09-data-integration.md +609 -0
- package/template/docs/framework/setup/10-auth-rbac.md +497 -0
- package/template/docs/framework/setup/11-hooks-api.md +393 -0
- package/template/docs/framework/setup/12-components-api.md +665 -0
- package/template/docs/framework/setup/13-deployment-guide.md +566 -0
- package/template/docs/framework/setup/README.md +548 -0
- package/template/host/e2e/auth.spec.ts +38 -0
- package/template/host/e2e/layout.spec.ts +38 -0
- package/template/host/modern.config.ts +19 -0
- package/template/host/package.json +71 -0
- package/template/host/playwright.config.ts +34 -0
- package/template/host/postcss.config.mjs +6 -0
- package/template/host/src/App.tsx +6 -0
- package/template/host/src/bootstrap.tsx +74 -0
- package/template/host/src/global.css +59 -0
- package/template/host/src/index.ts +2 -0
- package/template/host/src/kernel/__tests__/lib-utils.test.ts +32 -0
- package/template/host/src/kernel/__tests__/rbac-hooks.test.tsx +114 -0
- package/template/host/src/kernel/__tests__/rbac-utils.test.ts +108 -0
- package/template/host/src/kernel/auth/ProtectedRoute.tsx +41 -0
- package/template/host/src/kernel/auth/components/LoginForm.tsx +97 -0
- package/template/host/src/kernel/auth/components/LogoutButton.tsx +79 -0
- package/template/host/src/kernel/auth/hooks.ts +174 -0
- package/template/host/src/kernel/auth/index.ts +5 -0
- package/template/host/src/kernel/auth/schemas.ts +27 -0
- package/template/host/src/kernel/auth/service.ts +197 -0
- package/template/host/src/kernel/auth/types.ts +36 -0
- package/template/host/src/kernel/channels/ChannelBus.ts +181 -0
- package/template/host/src/kernel/channels/ChannelProvider.tsx +57 -0
- package/template/host/src/kernel/channels/events.ts +27 -0
- package/template/host/src/kernel/channels/hooks.ts +168 -0
- package/template/host/src/kernel/channels/index.ts +6 -0
- package/template/host/src/kernel/channels/integrations/ToastIntegration.tsx +60 -0
- package/template/host/src/kernel/channels/plugin-hooks.ts +72 -0
- package/template/host/src/kernel/channels/types.ts +112 -0
- package/template/host/src/kernel/components/__tests__/Badge.test.tsx +35 -0
- package/template/host/src/kernel/components/__tests__/Button.test.tsx +63 -0
- package/template/host/src/kernel/components/__tests__/Input.test.tsx +64 -0
- package/template/host/src/kernel/components/index.ts +32 -0
- package/template/host/src/kernel/components/ui/alert.tsx +58 -0
- package/template/host/src/kernel/components/ui/avatar.tsx +47 -0
- package/template/host/src/kernel/components/ui/badge.tsx +35 -0
- package/template/host/src/kernel/components/ui/button.tsx +50 -0
- package/template/host/src/kernel/components/ui/card.tsx +78 -0
- package/template/host/src/kernel/components/ui/dialog.tsx +116 -0
- package/template/host/src/kernel/components/ui/dropdown-menu.tsx +192 -0
- package/template/host/src/kernel/components/ui/index.ts +7 -0
- package/template/host/src/kernel/components/ui/input.tsx +24 -0
- package/template/host/src/kernel/components/ui/label.tsx +21 -0
- package/template/host/src/kernel/components/ui/popover.tsx +28 -0
- package/template/host/src/kernel/components/ui/progress.tsx +25 -0
- package/template/host/src/kernel/components/ui/scroll-area.tsx +45 -0
- package/template/host/src/kernel/components/ui/select.tsx +155 -0
- package/template/host/src/kernel/components/ui/separator.tsx +28 -0
- package/template/host/src/kernel/components/ui/skeleton.tsx +15 -0
- package/template/host/src/kernel/components/ui/switch.tsx +26 -0
- package/template/host/src/kernel/components/ui/table.tsx +116 -0
- package/template/host/src/kernel/components/ui/tabs.tsx +52 -0
- package/template/host/src/kernel/components/ui/toast.tsx +126 -0
- package/template/host/src/kernel/components/ui/toaster.tsx +34 -0
- package/template/host/src/kernel/components/ui/tooltip.tsx +27 -0
- package/template/host/src/kernel/components/ui/use-toast.ts +183 -0
- package/template/host/src/kernel/index.ts +48 -0
- package/template/host/src/kernel/lib/cn.ts +1 -0
- package/template/host/src/kernel/lib/utils.ts +36 -0
- package/template/host/src/kernel/plugins/Slot.tsx +41 -0
- package/template/host/src/kernel/plugins/SlotProvider.tsx +88 -0
- package/template/host/src/kernel/plugins/index.ts +23 -0
- package/template/host/src/kernel/plugins/loader.ts +122 -0
- package/template/host/src/kernel/plugins/schemas.ts +54 -0
- package/template/host/src/kernel/plugins/store.ts +185 -0
- package/template/host/src/kernel/plugins/types.ts +103 -0
- package/template/host/src/kernel/providers/PocketBaseProvider.tsx +70 -0
- package/template/host/src/kernel/providers/QueryProvider.tsx +28 -0
- package/template/host/src/kernel/providers/ThemeProvider.tsx +25 -0
- package/template/host/src/kernel/providers/index.ts +3 -0
- package/template/host/src/kernel/rbac/components/OrganizationSelector.tsx +69 -0
- package/template/host/src/kernel/rbac/components/PermissionGate.tsx +43 -0
- package/template/host/src/kernel/rbac/hooks.ts +379 -0
- package/template/host/src/kernel/rbac/index.ts +6 -0
- package/template/host/src/kernel/rbac/service.ts +504 -0
- package/template/host/src/kernel/rbac/types.ts +164 -0
- package/template/host/src/kernel/rbac/utils.ts +34 -0
- package/template/host/src/kernel/shared-state/bridge.ts +31 -0
- package/template/host/src/kernel/shared-state/index.ts +3 -0
- package/template/host/src/kernel/shared-state/store.ts +62 -0
- package/template/host/src/kernel/shared-state/types.ts +60 -0
- package/template/host/src/kernel/use-migrations.ts +72 -0
- package/template/host/src/layout/MobileMenu.tsx +61 -0
- package/template/host/src/layout/Shell.tsx +42 -0
- package/template/host/src/layout/Sidebar.tsx +178 -0
- package/template/host/src/layout/Topbar.tsx +50 -0
- package/template/host/src/layout/index.ts +4 -0
- package/template/host/src/lib/pocketbase/client.ts +38 -0
- package/template/host/src/lib/pocketbase/collections/audit_logs.ts +87 -0
- package/template/host/src/lib/pocketbase/collections/index.ts +19 -0
- package/template/host/src/lib/pocketbase/collections/organizations.ts +63 -0
- package/template/host/src/lib/pocketbase/collections/permissions.ts +57 -0
- package/template/host/src/lib/pocketbase/collections/roles.ts +55 -0
- package/template/host/src/lib/pocketbase/collections/todos.ts +74 -0
- package/template/host/src/lib/pocketbase/collections/user_roles.ts +57 -0
- package/template/host/src/lib/pocketbase/collections/users.ts +43 -0
- package/template/host/src/lib/pocketbase/index.ts +5 -0
- package/template/host/src/lib/pocketbase/migrations.ts +44 -0
- package/template/host/src/lib/pocketbase/seed/permissions.ts +8 -0
- package/template/host/src/lib/pocketbase/seed/roles.ts +22 -0
- package/template/host/src/lib/pocketbase/seed.ts +113 -0
- package/template/host/src/lib/pocketbase/types.ts +102 -0
- package/template/host/src/modern.runtime.ts +26 -0
- package/template/host/src/plugins.d.ts +9 -0
- package/template/host/src/providers/PocketBaseProvider.tsx +30 -0
- package/template/host/src/routes/_.tsx +6 -0
- package/template/host/src/routes/dashboard._.tsx +41 -0
- package/template/host/src/routes/index.tsx +93 -0
- package/template/host/src/routes/login.tsx +36 -0
- package/template/host/src/saas.config.ts +52 -0
- package/template/host/src/test/setup.ts +65 -0
- package/template/host/src/test/utils.tsx +69 -0
- package/template/host/src/test/vitest-globals.d.ts +19 -0
- package/template/host/src/vite-env.d.ts +16 -0
- package/template/host/tailwind.config.ts +77 -0
- package/template/host/tsconfig.json +19 -0
- package/template/host/vitest.config.ts +30 -0
- package/template/nginx.conf +72 -0
- package/template/package.json +44 -0
- package/template/packages/plugins/@lego/plugin-dashboard/modern.config.ts +19 -0
- package/template/packages/plugins/@lego/plugin-dashboard/package.json +35 -0
- package/template/packages/plugins/@lego/plugin-dashboard/postcss.config.mjs +6 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/App.tsx +27 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/components/ActivityFeed.tsx +63 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/components/QuickActionSlot.tsx +11 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/components/QuickActions.tsx +68 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/components/SidebarWidget.tsx +35 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/components/StatCard.tsx +47 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/global.css +24 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/hooks/useChannelIntegration.ts +43 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/hooks/useDashboardStats.ts +65 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/hooks/usePocketBase.ts +47 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/hooks/useRecentActivity.ts +55 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/lib/utils.ts +6 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/pages/DashboardPage.tsx +105 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/plugin.config.ts +121 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/plugin.ts +18 -0
- package/template/packages/plugins/@lego/plugin-dashboard/src/vite-env.d.ts +32 -0
- package/template/packages/plugins/@lego/plugin-dashboard/tailwind.config.ts +35 -0
- package/template/packages/plugins/@lego/plugin-dashboard/tsconfig.json +18 -0
- package/template/packages/plugins/@lego/plugin-todo/modern.config.ts +18 -0
- package/template/packages/plugins/@lego/plugin-todo/package.json +41 -0
- package/template/packages/plugins/@lego/plugin-todo/postcss.config.mjs +6 -0
- package/template/packages/plugins/@lego/plugin-todo/src/App.tsx +12 -0
- package/template/packages/plugins/@lego/plugin-todo/src/components/SidebarWidget.tsx +16 -0
- package/template/packages/plugins/@lego/plugin-todo/src/components/TodoDialog.tsx +55 -0
- package/template/packages/plugins/@lego/plugin-todo/src/components/TodoFilters.tsx +79 -0
- package/template/packages/plugins/@lego/plugin-todo/src/components/TodoForm.tsx +94 -0
- package/template/packages/plugins/@lego/plugin-todo/src/components/TodoItem.tsx +121 -0
- package/template/packages/plugins/@lego/plugin-todo/src/components/TodoList.tsx +41 -0
- package/template/packages/plugins/@lego/plugin-todo/src/components/index.ts +6 -0
- package/template/packages/plugins/@lego/plugin-todo/src/global.css +59 -0
- package/template/packages/plugins/@lego/plugin-todo/src/hooks/useCreateTodo.ts +62 -0
- package/template/packages/plugins/@lego/plugin-todo/src/hooks/useDeleteTodo.ts +46 -0
- package/template/packages/plugins/@lego/plugin-todo/src/hooks/usePocketBase.ts +38 -0
- package/template/packages/plugins/@lego/plugin-todo/src/hooks/useTodos.ts +64 -0
- package/template/packages/plugins/@lego/plugin-todo/src/hooks/useUpdateTodo.ts +35 -0
- package/template/packages/plugins/@lego/plugin-todo/src/index.tsx +5 -0
- package/template/packages/plugins/@lego/plugin-todo/src/lib/utils.ts +20 -0
- package/template/packages/plugins/@lego/plugin-todo/src/pages/TodoPage.tsx +89 -0
- package/template/packages/plugins/@lego/plugin-todo/src/plugin.config.ts +104 -0
- package/template/packages/plugins/@lego/plugin-todo/src/plugin.ts +13 -0
- package/template/packages/plugins/@lego/plugin-todo/src/schemas.ts +37 -0
- package/template/packages/plugins/@lego/plugin-todo/src/types.ts +42 -0
- package/template/packages/plugins/@lego/plugin-todo/src/vite-env.d.ts +31 -0
- package/template/packages/plugins/@lego/plugin-todo/tailwind.config.ts +51 -0
- package/template/packages/plugins/@lego/plugin-todo/tsconfig.json +18 -0
- package/template/pnpm-workspace.yaml +4 -0
- package/template/pocketbase/CHANGELOG.md +911 -0
- package/template/pocketbase/LICENSE.md +17 -0
- package/template/scripts/create-plugin.js +221 -0
- package/template/scripts/deploy.sh +56 -0
- package/template/tsconfig.base.json +26 -0
- package/template/tsconfig.json +8 -0
|
@@ -0,0 +1,1466 @@
|
|
|
1
|
+
# Authentication System Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For AI Implementing This Plan:** This is document 04 of 13. Complete documents 01-03 first.
|
|
4
|
+
|
|
5
|
+
**Goal:** Implement complete authentication system with login, logout, session management, and protected routes using PocketBase auth collection.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Admin-seeded authentication system (no public signup). Admin manages users through settings. Session persistence via PocketBase auth store + localStorage. Protected routes using higher-order components.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** PocketBase auth, React hooks, Zustand state management, Zod validation, TanStack Query
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Prerequisites
|
|
14
|
+
|
|
15
|
+
- ✅ Completed `01-infrastructure-setup.md`
|
|
16
|
+
- ✅ Completed `02-pocketbase-setup.md` (users collection, seed data)
|
|
17
|
+
- ✅ Completed `03-host-kernel.md` (host app, shared state, providers)
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Task 1: Create Auth Types and Interfaces
|
|
22
|
+
|
|
23
|
+
**Files:**
|
|
24
|
+
- Create: `host/src/kernel/auth/types.ts`
|
|
25
|
+
- Create: `host/src/kernel/auth/schemas.ts`
|
|
26
|
+
|
|
27
|
+
### Step 1: Create auth types
|
|
28
|
+
|
|
29
|
+
**File:** `host/src/kernel/auth/types.ts`
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import type { RecordAuth } from 'pocketbase';
|
|
33
|
+
|
|
34
|
+
export interface LoginCredentials {
|
|
35
|
+
email: string;
|
|
36
|
+
password: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RegisterData {
|
|
40
|
+
email: string;
|
|
41
|
+
password: string;
|
|
42
|
+
passwordConfirm: string;
|
|
43
|
+
name: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface User {
|
|
47
|
+
id: string;
|
|
48
|
+
email: string;
|
|
49
|
+
name: string;
|
|
50
|
+
avatar?: string;
|
|
51
|
+
role?: string;
|
|
52
|
+
organizationId?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface AuthResponse {
|
|
56
|
+
token: string;
|
|
57
|
+
user: User;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AuthError {
|
|
61
|
+
message: string;
|
|
62
|
+
code?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface SessionData {
|
|
66
|
+
token: string;
|
|
67
|
+
user: User;
|
|
68
|
+
organizationId?: string;
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Step 2: Create Zod validation schemas
|
|
73
|
+
|
|
74
|
+
**File:** `host/src/kernel/auth/schemas.ts`
|
|
75
|
+
|
|
76
|
+
```typescript
|
|
77
|
+
import { z } from 'zod';
|
|
78
|
+
|
|
79
|
+
export const loginSchema = z.object({
|
|
80
|
+
email: z.string().email('Invalid email address'),
|
|
81
|
+
password: z.string().min(8, 'Password must be at least 8 characters'),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
export type LoginFormData = z.infer<typeof loginSchema>;
|
|
85
|
+
|
|
86
|
+
export const registerSchema = z
|
|
87
|
+
.object({
|
|
88
|
+
email: z.string().email('Invalid email address'),
|
|
89
|
+
password: z
|
|
90
|
+
.string()
|
|
91
|
+
.min(8, 'Password must be at least 8 characters')
|
|
92
|
+
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
|
|
93
|
+
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
|
|
94
|
+
.regex(/[0-9]/, 'Password must contain at least one number'),
|
|
95
|
+
passwordConfirm: z.string(),
|
|
96
|
+
name: z.string().min(2, 'Name must be at least 2 characters'),
|
|
97
|
+
})
|
|
98
|
+
.refine((data) => data.password === data.passwordConfirm, {
|
|
99
|
+
message: 'Passwords do not match',
|
|
100
|
+
path: ['passwordConfirm'],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
export type RegisterFormData = z.infer<typeof registerSchema>;
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Task 2: Create Auth Service
|
|
109
|
+
|
|
110
|
+
**Files:**
|
|
111
|
+
- Create: `host/src/kernel/auth/service.ts`
|
|
112
|
+
|
|
113
|
+
### Step 1: Create auth service
|
|
114
|
+
|
|
115
|
+
**File:** `host/src/kernel/auth/service.ts`
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
import PocketBase from 'pocketbase';
|
|
119
|
+
import type { LoginCredentials, RegisterData, User, AuthResponse, AuthError } from './types';
|
|
120
|
+
import type { Record } from 'pocketbase';
|
|
121
|
+
|
|
122
|
+
// Map PocketBase auth record to our User type
|
|
123
|
+
function mapAuthRecordToUser(record: Record): User {
|
|
124
|
+
return {
|
|
125
|
+
id: record.id,
|
|
126
|
+
email: record.email,
|
|
127
|
+
name: record.name || record.email,
|
|
128
|
+
avatar: record.avatar,
|
|
129
|
+
role: record.role,
|
|
130
|
+
organizationId: record.organizationId,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export class AuthService {
|
|
135
|
+
constructor(private pb: PocketBase) {}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Login with email and password
|
|
139
|
+
*/
|
|
140
|
+
async login(credentials: LoginCredentials): Promise<AuthResponse> {
|
|
141
|
+
try {
|
|
142
|
+
const authData = await this.pb.collection('users').authWithPassword(
|
|
143
|
+
credentials.email,
|
|
144
|
+
credentials.password
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
token: authData.token,
|
|
149
|
+
user: mapAuthRecordToUser(authData.record),
|
|
150
|
+
};
|
|
151
|
+
} catch (error: any) {
|
|
152
|
+
throw this.handleError(error);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Register new user (admin only in our system)
|
|
158
|
+
*/
|
|
159
|
+
async register(data: RegisterData, organizationId?: string): Promise<AuthResponse> {
|
|
160
|
+
try {
|
|
161
|
+
const record = await this.pb.collection('users').create({
|
|
162
|
+
email: data.email,
|
|
163
|
+
password: data.password,
|
|
164
|
+
passwordConfirm: data.passwordConfirm,
|
|
165
|
+
name: data.name,
|
|
166
|
+
organizationId,
|
|
167
|
+
role: 'member', // Default role
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Auto-login after registration
|
|
171
|
+
const authData = await this.pb.collection('users').authWithPassword(
|
|
172
|
+
data.email,
|
|
173
|
+
data.password
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
token: authData.token,
|
|
178
|
+
user: mapAuthRecordToUser(authData.record),
|
|
179
|
+
};
|
|
180
|
+
} catch (error: any) {
|
|
181
|
+
throw this.handleError(error);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Logout current user
|
|
187
|
+
*/
|
|
188
|
+
async logout(): Promise<void> {
|
|
189
|
+
this.pb.authStore.clear();
|
|
190
|
+
localStorage.removeItem('pocketbase_auth');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Get current authenticated user
|
|
195
|
+
*/
|
|
196
|
+
getCurrentUser(): User | null {
|
|
197
|
+
if (!this.pb.authStore.isValid) return null;
|
|
198
|
+
|
|
199
|
+
const model = this.pb.authStore.model;
|
|
200
|
+
if (!model) return null;
|
|
201
|
+
|
|
202
|
+
return mapAuthRecordToUser(model);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Get current auth token
|
|
207
|
+
*/
|
|
208
|
+
getToken(): string | null {
|
|
209
|
+
return this.pb.authStore.token;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Refresh auth token
|
|
214
|
+
*/
|
|
215
|
+
async refreshToken(): Promise<string> {
|
|
216
|
+
try {
|
|
217
|
+
// PocketBase handles token refresh automatically
|
|
218
|
+
// This is a no-op but kept for interface consistency
|
|
219
|
+
return this.pb.authStore.token;
|
|
220
|
+
} catch (error: any) {
|
|
221
|
+
throw this.handleError(error);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Update user profile
|
|
227
|
+
*/
|
|
228
|
+
async updateProfile(userId: string, data: Partial<User>): Promise<User> {
|
|
229
|
+
try {
|
|
230
|
+
const record = await this.pb.collection('users').update(userId, {
|
|
231
|
+
name: data.name,
|
|
232
|
+
avatar: data.avatar,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return mapAuthRecordToUser(record);
|
|
236
|
+
} catch (error: any) {
|
|
237
|
+
throw this.handleError(error);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Change password
|
|
243
|
+
*/
|
|
244
|
+
async changePassword(oldPassword: string, newPassword: string): Promise<void> {
|
|
245
|
+
try {
|
|
246
|
+
await this.pb.collection('users').update(this.pb.authStore.record?.id, {
|
|
247
|
+
oldPassword,
|
|
248
|
+
password: newPassword,
|
|
249
|
+
passwordConfirm: newPassword,
|
|
250
|
+
});
|
|
251
|
+
} catch (error: any) {
|
|
252
|
+
throw this.handleError(error);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Request password reset
|
|
258
|
+
*/
|
|
259
|
+
async requestPasswordReset(email: string): Promise<void> {
|
|
260
|
+
try {
|
|
261
|
+
await this.pb.collection('users').requestPasswordReset(email);
|
|
262
|
+
} catch (error: any) {
|
|
263
|
+
throw this.handleError(error);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Confirm password reset
|
|
269
|
+
*/
|
|
270
|
+
async confirmPasswordReset(token: string, password: string): Promise<void> {
|
|
271
|
+
try {
|
|
272
|
+
await this.pb.collection('users').confirmPasswordReset(token, password, password);
|
|
273
|
+
} catch (error: any) {
|
|
274
|
+
throw this.handleError(error);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Handle PocketBase errors
|
|
280
|
+
*/
|
|
281
|
+
private handleError(error: any): AuthError {
|
|
282
|
+
// PocketBase error format
|
|
283
|
+
if (error?.data?.message) {
|
|
284
|
+
return {
|
|
285
|
+
message: error.data.message,
|
|
286
|
+
code: error.status,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (error?.message) {
|
|
291
|
+
return {
|
|
292
|
+
message: error.message,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
message: 'An unexpected error occurred',
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Check if user is authenticated
|
|
303
|
+
*/
|
|
304
|
+
isAuthenticated(): boolean {
|
|
305
|
+
return this.pb.authStore.isValid;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Listen to auth state changes
|
|
310
|
+
*/
|
|
311
|
+
onAuthChange(callback: (token: string, record: Record | null) => void): () => void {
|
|
312
|
+
return this.pb.authStore.onChange(callback);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
## Task 3: Create Auth Hooks
|
|
320
|
+
|
|
321
|
+
**Files:**
|
|
322
|
+
- Create: `host/src/kernel/auth/hooks.ts`
|
|
323
|
+
|
|
324
|
+
### Step 1: Create auth hooks
|
|
325
|
+
|
|
326
|
+
**File:** `host/src/kernel/auth/hooks.ts`
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
330
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
331
|
+
import { usePocketBase } from '../providers';
|
|
332
|
+
import { AuthService } from './service';
|
|
333
|
+
import type { LoginCredentials, RegisterData, User } from './types';
|
|
334
|
+
import { useGlobalKernelState } from '../shared-state';
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Get auth service instance
|
|
338
|
+
*/
|
|
339
|
+
function useAuthService(): AuthService | null {
|
|
340
|
+
const pb = usePocketBase();
|
|
341
|
+
if (!pb) return null;
|
|
342
|
+
return new AuthService(pb);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Main auth hook - provides auth state and actions
|
|
347
|
+
*/
|
|
348
|
+
export function useAuth() {
|
|
349
|
+
const pb = usePocketBase();
|
|
350
|
+
const queryClient = useQueryClient();
|
|
351
|
+
const { setUser, setToken, clearAuth, setIsLoading } = useGlobalKernelState();
|
|
352
|
+
const [authService, setAuthService] = useState<AuthService | null>(null);
|
|
353
|
+
|
|
354
|
+
// Initialize auth service
|
|
355
|
+
useEffect(() => {
|
|
356
|
+
if (pb) {
|
|
357
|
+
setAuthService(new AuthService(pb));
|
|
358
|
+
|
|
359
|
+
// Restore session from PocketBase auth store
|
|
360
|
+
if (pb.authStore.isValid && pb.authStore.model) {
|
|
361
|
+
setUser({
|
|
362
|
+
id: pb.authStore.model.id,
|
|
363
|
+
email: pb.authStore.model.email,
|
|
364
|
+
name: pb.authStore.model.name || pb.authStore.model.email,
|
|
365
|
+
avatar: pb.authStore.model.avatar,
|
|
366
|
+
role: pb.authStore.model.role,
|
|
367
|
+
organizationId: pb.authStore.model.organizationId,
|
|
368
|
+
});
|
|
369
|
+
setToken(pb.authStore.token);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
setIsLoading(false);
|
|
373
|
+
}
|
|
374
|
+
}, [pb, setUser, setToken, setIsLoading]);
|
|
375
|
+
|
|
376
|
+
// Listen to auth state changes
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
if (!pb) return;
|
|
379
|
+
|
|
380
|
+
const unsubscribe = pb.authStore.onChange((token, model) => {
|
|
381
|
+
if (model && token) {
|
|
382
|
+
setUser({
|
|
383
|
+
id: model.id,
|
|
384
|
+
email: model.email,
|
|
385
|
+
name: model.name || model.email,
|
|
386
|
+
avatar: model.avatar,
|
|
387
|
+
role: model.role,
|
|
388
|
+
organizationId: model.organizationId,
|
|
389
|
+
});
|
|
390
|
+
setToken(token);
|
|
391
|
+
} else {
|
|
392
|
+
clearAuth();
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return unsubscribe;
|
|
397
|
+
}, [pb, setUser, setToken, clearAuth]);
|
|
398
|
+
|
|
399
|
+
const loginMutation = useMutation({
|
|
400
|
+
mutationFn: async (credentials: LoginCredentials) => {
|
|
401
|
+
if (!authService) throw new Error('Auth service not initialized');
|
|
402
|
+
return authService.login(credentials);
|
|
403
|
+
},
|
|
404
|
+
onSuccess: (data) => {
|
|
405
|
+
setUser(data.user);
|
|
406
|
+
setToken(data.token);
|
|
407
|
+
queryClient.invalidateQueries({ queryKey: ['auth', 'user'] });
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const registerMutation = useMutation({
|
|
412
|
+
mutationFn: async (data: { registerData: RegisterData; organizationId?: string }) => {
|
|
413
|
+
if (!authService) throw new Error('Auth service not initialized');
|
|
414
|
+
return authService.register(data.registerData, data.organizationId);
|
|
415
|
+
},
|
|
416
|
+
onSuccess: (data) => {
|
|
417
|
+
setUser(data.user);
|
|
418
|
+
setToken(data.token);
|
|
419
|
+
queryClient.invalidateQueries({ queryKey: ['auth', 'user'] });
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const logoutMutation = useMutation({
|
|
424
|
+
mutationFn: async () => {
|
|
425
|
+
if (!authService) throw new Error('Auth service not initialized');
|
|
426
|
+
return authService.logout();
|
|
427
|
+
},
|
|
428
|
+
onSuccess: () => {
|
|
429
|
+
clearAuth();
|
|
430
|
+
queryClient.clear();
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const login = useCallback(
|
|
435
|
+
(credentials: LoginCredentials) => {
|
|
436
|
+
return loginMutation.mutateAsync(credentials);
|
|
437
|
+
},
|
|
438
|
+
[loginMutation]
|
|
439
|
+
);
|
|
440
|
+
|
|
441
|
+
const register = useCallback(
|
|
442
|
+
(registerData: RegisterData, organizationId?: string) => {
|
|
443
|
+
return registerMutation.mutateAsync({ registerData: registerData, organizationId });
|
|
444
|
+
},
|
|
445
|
+
[registerMutation]
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
const logout = useCallback(() => {
|
|
449
|
+
return logoutMutation.mutateAsync();
|
|
450
|
+
}, [logoutMutation]);
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
user: authService?.getCurrentUser() || null,
|
|
454
|
+
isAuthenticated: !!pb?.authStore.isValid,
|
|
455
|
+
isLoading: loginMutation.isPending || registerMutation.isPending || logoutMutation.isPending,
|
|
456
|
+
login,
|
|
457
|
+
register,
|
|
458
|
+
logout,
|
|
459
|
+
error: loginMutation.error || registerMutation.error || logoutMutation.error,
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Get current user data
|
|
465
|
+
*/
|
|
466
|
+
export function useCurrentUser() {
|
|
467
|
+
const { user, isAuthenticated } = useAuth();
|
|
468
|
+
|
|
469
|
+
const { data: currentUser, isLoading } = useQuery({
|
|
470
|
+
queryKey: ['auth', 'user'],
|
|
471
|
+
queryFn: () => Promise.resolve(user),
|
|
472
|
+
enabled: isAuthenticated,
|
|
473
|
+
staleTime: Infinity,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
return {
|
|
477
|
+
user: currentUser,
|
|
478
|
+
isLoading,
|
|
479
|
+
isAuthenticated,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Require authentication - redirect if not logged in
|
|
485
|
+
*/
|
|
486
|
+
export function useRequireAuth() {
|
|
487
|
+
const { isAuthenticated, isLoading, user } = useAuth();
|
|
488
|
+
const [shouldRedirect, setShouldRedirect] = useState(false);
|
|
489
|
+
|
|
490
|
+
useEffect(() => {
|
|
491
|
+
if (!isLoading && !isAuthenticated) {
|
|
492
|
+
setShouldRedirect(true);
|
|
493
|
+
}
|
|
494
|
+
}, [isLoading, isAuthenticated]);
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
user,
|
|
498
|
+
isAuthenticated,
|
|
499
|
+
isLoading,
|
|
500
|
+
shouldRedirect,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
---
|
|
506
|
+
|
|
507
|
+
## Task 4: Create Protected Route Component
|
|
508
|
+
|
|
509
|
+
**Files:**
|
|
510
|
+
- Create: `host/src/kernel/auth/ProtectedRoute.tsx`
|
|
511
|
+
|
|
512
|
+
### Step 1: Create protected route wrapper
|
|
513
|
+
|
|
514
|
+
**File:** `host/src/kernel/auth/ProtectedRoute.tsx`
|
|
515
|
+
|
|
516
|
+
```typescript
|
|
517
|
+
import { useEffect } from 'react';
|
|
518
|
+
import { useNavigate } from '@modern-js/runtime/router';
|
|
519
|
+
import { useRequireAuth } from './hooks';
|
|
520
|
+
import { Skeleton } from '../components/ui/skeleton';
|
|
521
|
+
|
|
522
|
+
interface ProtectedRouteProps {
|
|
523
|
+
children: React.ReactNode;
|
|
524
|
+
redirectTo?: string;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function ProtectedRoute({
|
|
528
|
+
children,
|
|
529
|
+
redirectTo = '/login',
|
|
530
|
+
}: ProtectedRouteProps) {
|
|
531
|
+
const { isAuthenticated, isLoading, shouldRedirect } = useRequireAuth();
|
|
532
|
+
const navigate = useNavigate();
|
|
533
|
+
|
|
534
|
+
useEffect(() => {
|
|
535
|
+
if (shouldRedirect) {
|
|
536
|
+
navigate(redirectTo);
|
|
537
|
+
}
|
|
538
|
+
}, [shouldRedirect, navigate, redirectTo]);
|
|
539
|
+
|
|
540
|
+
if (isLoading) {
|
|
541
|
+
return (
|
|
542
|
+
<div className="flex min-h-[400px] items-center justify-center">
|
|
543
|
+
<div className="space-y-4 text-center">
|
|
544
|
+
<Skeleton className="mx-auto h-12 w-12 rounded-full" />
|
|
545
|
+
<Skeleton className="mx-auto h-4 w-48" />
|
|
546
|
+
<Skeleton className="mx-auto h-4 w-32" />
|
|
547
|
+
</div>
|
|
548
|
+
</div>
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (!isAuthenticated) {
|
|
553
|
+
return null; // Will redirect
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return <>{children}</>;
|
|
557
|
+
}
|
|
558
|
+
```
|
|
559
|
+
|
|
560
|
+
---
|
|
561
|
+
|
|
562
|
+
## Task 5: Create Login Page
|
|
563
|
+
|
|
564
|
+
**Files:**
|
|
565
|
+
- Create: `host/src/routes/login.tsx`
|
|
566
|
+
- Create: `host/src/kernel/auth/components/LoginForm.tsx`
|
|
567
|
+
|
|
568
|
+
### Step 1: Create login form component
|
|
569
|
+
|
|
570
|
+
**File:** `host/src/kernel/auth/components/LoginForm.tsx`
|
|
571
|
+
|
|
572
|
+
```typescript
|
|
573
|
+
import { useState } from 'react';
|
|
574
|
+
import { useNavigate } from '@modern-js/runtime/router';
|
|
575
|
+
import { useAuth } from '../hooks';
|
|
576
|
+
import { loginSchema, type LoginFormData } from '../schemas';
|
|
577
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
578
|
+
import { useForm } from 'react-hook-form';
|
|
579
|
+
import { Button } from '../../components/ui/button';
|
|
580
|
+
import { Input } from '../../components/ui/input';
|
|
581
|
+
import { Label } from '../../components/ui/label';
|
|
582
|
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '../../components/ui/card';
|
|
583
|
+
import { Alert, AlertDescription } from '../../components/ui/alert';
|
|
584
|
+
import { Loader2 } from 'lucide-react';
|
|
585
|
+
|
|
586
|
+
export function LoginForm() {
|
|
587
|
+
const navigate = useNavigate();
|
|
588
|
+
const { login, isLoading, error } = useAuth();
|
|
589
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
590
|
+
|
|
591
|
+
const {
|
|
592
|
+
register,
|
|
593
|
+
handleSubmit,
|
|
594
|
+
formState: { errors },
|
|
595
|
+
} = useForm<LoginFormData>({
|
|
596
|
+
resolver: zodResolver(loginSchema),
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
const onSubmit = async (data: LoginFormData) => {
|
|
600
|
+
setSubmitError(null);
|
|
601
|
+
try {
|
|
602
|
+
await login(data);
|
|
603
|
+
navigate('/dashboard');
|
|
604
|
+
} catch (err: any) {
|
|
605
|
+
setSubmitError(err.message || 'Login failed. Please try again.');
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
return (
|
|
610
|
+
<Card className="w-full max-w-md">
|
|
611
|
+
<CardHeader>
|
|
612
|
+
<CardTitle className="text-2xl">Sign In</CardTitle>
|
|
613
|
+
<CardDescription>
|
|
614
|
+
Enter your credentials to access your account
|
|
615
|
+
</CardDescription>
|
|
616
|
+
</CardHeader>
|
|
617
|
+
<form onSubmit={handleSubmit(onSubmit)}>
|
|
618
|
+
<CardContent className="space-y-4">
|
|
619
|
+
{(submitError || error) && (
|
|
620
|
+
<Alert variant="destructive">
|
|
621
|
+
<AlertDescription>
|
|
622
|
+
{submitError || (error as any)?.message}
|
|
623
|
+
</AlertDescription>
|
|
624
|
+
</Alert>
|
|
625
|
+
)}
|
|
626
|
+
|
|
627
|
+
<div className="space-y-2">
|
|
628
|
+
<Label htmlFor="email">Email</Label>
|
|
629
|
+
<Input
|
|
630
|
+
id="email"
|
|
631
|
+
type="email"
|
|
632
|
+
placeholder="admin@example.com"
|
|
633
|
+
{...register('email')}
|
|
634
|
+
/>
|
|
635
|
+
{errors.email && (
|
|
636
|
+
<p className="text-sm text-destructive">{errors.email.message}</p>
|
|
637
|
+
)}
|
|
638
|
+
</div>
|
|
639
|
+
|
|
640
|
+
<div className="space-y-2">
|
|
641
|
+
<Label htmlFor="password">Password</Label>
|
|
642
|
+
<Input
|
|
643
|
+
id="password"
|
|
644
|
+
type="password"
|
|
645
|
+
placeholder="••••••••"
|
|
646
|
+
{...register('password')}
|
|
647
|
+
/>
|
|
648
|
+
{errors.password && (
|
|
649
|
+
<p className="text-sm text-destructive">{errors.password.message}</p>
|
|
650
|
+
)}
|
|
651
|
+
</div>
|
|
652
|
+
</CardContent>
|
|
653
|
+
|
|
654
|
+
<CardFooter>
|
|
655
|
+
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
656
|
+
{isLoading ? (
|
|
657
|
+
<>
|
|
658
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
659
|
+
Signing in...
|
|
660
|
+
</>
|
|
661
|
+
) : (
|
|
662
|
+
'Sign In'
|
|
663
|
+
)}
|
|
664
|
+
</Button>
|
|
665
|
+
</CardFooter>
|
|
666
|
+
</form>
|
|
667
|
+
</Card>
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
### Step 2: Create login page route
|
|
673
|
+
|
|
674
|
+
**File:** `host/src/routes/login.tsx`
|
|
675
|
+
|
|
676
|
+
```typescript
|
|
677
|
+
import { Link } from '@modern-js/runtime/router';
|
|
678
|
+
import { LoginForm } from '../kernel/auth/components/LoginForm';
|
|
679
|
+
import { useAuth } from '../kernel/auth/hooks';
|
|
680
|
+
import { Navigate } from '@modern-js/runtime/router';
|
|
681
|
+
|
|
682
|
+
export default function LoginPage() {
|
|
683
|
+
const { isAuthenticated } = useAuth();
|
|
684
|
+
|
|
685
|
+
if (isAuthenticated) {
|
|
686
|
+
return <Navigate to="/dashboard" replace />;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return (
|
|
690
|
+
<div className="flex min-h-[calc(100vh-4rem)] items-center justify-center p-6">
|
|
691
|
+
<div className="w-full max-w-md space-y-6">
|
|
692
|
+
{/* Logo and branding */}
|
|
693
|
+
<div className="text-center">
|
|
694
|
+
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-lg bg-primary text-primary-foreground text-xl font-bold">
|
|
695
|
+
L
|
|
696
|
+
</div>
|
|
697
|
+
<h1 className="mt-4 text-2xl font-bold">Lego-One</h1>
|
|
698
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
699
|
+
Sign in to access your account
|
|
700
|
+
</p>
|
|
701
|
+
</div>
|
|
702
|
+
|
|
703
|
+
{/* Login form */}
|
|
704
|
+
<LoginForm />
|
|
705
|
+
|
|
706
|
+
{/* Help text */}
|
|
707
|
+
<div className="text-center text-sm text-muted-foreground">
|
|
708
|
+
<p>Default credentials: admin@example.com / admin123</p>
|
|
709
|
+
</div>
|
|
710
|
+
</div>
|
|
711
|
+
</div>
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
---
|
|
717
|
+
|
|
718
|
+
## Task 6: Create Shadcn UI Components (Auth-Needed)
|
|
719
|
+
|
|
720
|
+
**Files:**
|
|
721
|
+
- Create: `host/src/kernel/components/ui/button.tsx`
|
|
722
|
+
- Create: `host/src/kernel/components/ui/input.tsx`
|
|
723
|
+
- Create: `host/src/kernel/components/ui/label.tsx`
|
|
724
|
+
- Create: `host/src/kernel/components/ui/card.tsx`
|
|
725
|
+
- Create: `host/src/kernel/components/ui/alert.tsx`
|
|
726
|
+
|
|
727
|
+
### Step 1: Create button component
|
|
728
|
+
|
|
729
|
+
**File:** `host/src/kernel/components/ui/button.tsx`
|
|
730
|
+
|
|
731
|
+
```typescript
|
|
732
|
+
import * as React from 'react';
|
|
733
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
734
|
+
import { cn } from '../../../lib/utils';
|
|
735
|
+
|
|
736
|
+
const buttonVariants = cva(
|
|
737
|
+
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
|
738
|
+
{
|
|
739
|
+
variants: {
|
|
740
|
+
variant: {
|
|
741
|
+
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
|
742
|
+
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
|
743
|
+
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
|
744
|
+
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
|
745
|
+
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
|
746
|
+
link: 'text-primary underline-offset-4 hover:underline',
|
|
747
|
+
},
|
|
748
|
+
size: {
|
|
749
|
+
default: 'h-10 px-4 py-2',
|
|
750
|
+
sm: 'h-9 rounded-md px-3',
|
|
751
|
+
lg: 'h-11 rounded-md px-8',
|
|
752
|
+
icon: 'h-10 w-10',
|
|
753
|
+
},
|
|
754
|
+
},
|
|
755
|
+
defaultVariants: {
|
|
756
|
+
variant: 'default',
|
|
757
|
+
size: 'default',
|
|
758
|
+
},
|
|
759
|
+
}
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
export interface ButtonProps
|
|
763
|
+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
|
764
|
+
VariantProps<typeof buttonVariants> {
|
|
765
|
+
asChild?: boolean;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
769
|
+
({ className, variant, size, ...props }, ref) => {
|
|
770
|
+
return (
|
|
771
|
+
<button
|
|
772
|
+
className={cn(buttonVariants({ variant, size, className }))}
|
|
773
|
+
ref={ref}
|
|
774
|
+
{...props}
|
|
775
|
+
/>
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
);
|
|
779
|
+
Button.displayName = 'Button';
|
|
780
|
+
|
|
781
|
+
export { Button, buttonVariants };
|
|
782
|
+
```
|
|
783
|
+
|
|
784
|
+
### Step 2: Create input component
|
|
785
|
+
|
|
786
|
+
**File:** `host/src/kernel/components/ui/input.tsx`
|
|
787
|
+
|
|
788
|
+
```typescript
|
|
789
|
+
import * as React from 'react';
|
|
790
|
+
import { cn } from '../../../lib/utils';
|
|
791
|
+
|
|
792
|
+
export interface InputProps
|
|
793
|
+
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
|
794
|
+
|
|
795
|
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
796
|
+
({ className, type, ...props }, ref) => {
|
|
797
|
+
return (
|
|
798
|
+
<input
|
|
799
|
+
type={type}
|
|
800
|
+
className={cn(
|
|
801
|
+
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
|
802
|
+
className
|
|
803
|
+
)}
|
|
804
|
+
ref={ref}
|
|
805
|
+
{...props}
|
|
806
|
+
/>
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
);
|
|
810
|
+
Input.displayName = 'Input';
|
|
811
|
+
|
|
812
|
+
export { Input };
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
### Step 3: Create label component
|
|
816
|
+
|
|
817
|
+
**File:** `host/src/kernel/components/ui/label.tsx`
|
|
818
|
+
|
|
819
|
+
```typescript
|
|
820
|
+
import * as React from 'react';
|
|
821
|
+
import { cn } from '../../../lib/utils';
|
|
822
|
+
|
|
823
|
+
export interface LabelProps
|
|
824
|
+
extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
|
825
|
+
|
|
826
|
+
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
|
827
|
+
({ className, ...props }, ref) => (
|
|
828
|
+
<label
|
|
829
|
+
ref={ref}
|
|
830
|
+
className={cn(
|
|
831
|
+
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
|
832
|
+
className
|
|
833
|
+
)}
|
|
834
|
+
{...props}
|
|
835
|
+
/>
|
|
836
|
+
)
|
|
837
|
+
);
|
|
838
|
+
Label.displayName = 'Label';
|
|
839
|
+
|
|
840
|
+
export { Label };
|
|
841
|
+
```
|
|
842
|
+
|
|
843
|
+
### Step 4: Create card components
|
|
844
|
+
|
|
845
|
+
**File:** `host/src/kernel/components/ui/card.tsx`
|
|
846
|
+
|
|
847
|
+
```typescript
|
|
848
|
+
import * as React from 'react';
|
|
849
|
+
import { cn } from '../../../lib/utils';
|
|
850
|
+
|
|
851
|
+
const Card = React.forwardRef<
|
|
852
|
+
HTMLDivElement,
|
|
853
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
854
|
+
>(({ className, ...props }, ref) => (
|
|
855
|
+
<div
|
|
856
|
+
ref={ref}
|
|
857
|
+
className={cn(
|
|
858
|
+
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
|
859
|
+
className
|
|
860
|
+
)}
|
|
861
|
+
{...props}
|
|
862
|
+
/>
|
|
863
|
+
));
|
|
864
|
+
Card.displayName = 'Card';
|
|
865
|
+
|
|
866
|
+
const CardHeader = React.forwardRef<
|
|
867
|
+
HTMLDivElement,
|
|
868
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
869
|
+
>(({ className, ...props }, ref) => (
|
|
870
|
+
<div
|
|
871
|
+
ref={ref}
|
|
872
|
+
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
|
873
|
+
{...props}
|
|
874
|
+
/>
|
|
875
|
+
));
|
|
876
|
+
CardHeader.displayName = 'CardHeader';
|
|
877
|
+
|
|
878
|
+
const CardTitle = React.forwardRef<
|
|
879
|
+
HTMLParagraphElement,
|
|
880
|
+
React.HTMLAttributes<HTMLHeadingElement>
|
|
881
|
+
>(({ className, ...props }, ref) => (
|
|
882
|
+
<h3
|
|
883
|
+
ref={ref}
|
|
884
|
+
className={cn(
|
|
885
|
+
'text-2xl font-semibold leading-none tracking-tight',
|
|
886
|
+
className
|
|
887
|
+
)}
|
|
888
|
+
{...props}
|
|
889
|
+
/>
|
|
890
|
+
));
|
|
891
|
+
CardTitle.displayName = 'CardTitle';
|
|
892
|
+
|
|
893
|
+
const CardDescription = React.forwardRef<
|
|
894
|
+
HTMLParagraphElement,
|
|
895
|
+
React.HTMLAttributes<HTMLParagraphElement>
|
|
896
|
+
>(({ className, ...props }, ref) => (
|
|
897
|
+
<p
|
|
898
|
+
ref={ref}
|
|
899
|
+
className={cn('text-sm text-muted-foreground', className)}
|
|
900
|
+
{...props}
|
|
901
|
+
/>
|
|
902
|
+
));
|
|
903
|
+
CardDescription.displayName = 'CardDescription';
|
|
904
|
+
|
|
905
|
+
const CardContent = React.forwardRef<
|
|
906
|
+
HTMLDivElement,
|
|
907
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
908
|
+
>(({ className, ...props }, ref) => (
|
|
909
|
+
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
|
910
|
+
));
|
|
911
|
+
CardContent.displayName = 'CardContent';
|
|
912
|
+
|
|
913
|
+
const CardFooter = React.forwardRef<
|
|
914
|
+
HTMLDivElement,
|
|
915
|
+
React.HTMLAttributes<HTMLDivElement>
|
|
916
|
+
>(({ className, ...props }, ref) => (
|
|
917
|
+
<div
|
|
918
|
+
ref={ref}
|
|
919
|
+
className={cn('flex items-center p-6 pt-0', className)}
|
|
920
|
+
{...props}
|
|
921
|
+
/>
|
|
922
|
+
));
|
|
923
|
+
CardFooter.displayName = 'CardFooter';
|
|
924
|
+
|
|
925
|
+
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
|
926
|
+
```
|
|
927
|
+
|
|
928
|
+
### Step 5: Create alert component
|
|
929
|
+
|
|
930
|
+
**File:** `host/src/kernel/components/ui/alert.tsx`
|
|
931
|
+
|
|
932
|
+
```typescript
|
|
933
|
+
import * as React from 'react';
|
|
934
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
935
|
+
import { cn } from '../../../lib/utils';
|
|
936
|
+
|
|
937
|
+
const alertVariants = cva(
|
|
938
|
+
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
|
|
939
|
+
{
|
|
940
|
+
variants: {
|
|
941
|
+
variant: {
|
|
942
|
+
default: 'bg-background text-foreground',
|
|
943
|
+
destructive:
|
|
944
|
+
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
|
945
|
+
},
|
|
946
|
+
},
|
|
947
|
+
defaultVariants: {
|
|
948
|
+
variant: 'default',
|
|
949
|
+
},
|
|
950
|
+
}
|
|
951
|
+
);
|
|
952
|
+
|
|
953
|
+
const Alert = React.forwardRef<
|
|
954
|
+
HTMLDivElement,
|
|
955
|
+
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
|
956
|
+
>(({ className, variant, ...props }, ref) => (
|
|
957
|
+
<div
|
|
958
|
+
ref={ref}
|
|
959
|
+
role="alert"
|
|
960
|
+
className={cn(alertVariants({ variant }), className)}
|
|
961
|
+
{...props}
|
|
962
|
+
/>
|
|
963
|
+
));
|
|
964
|
+
Alert.displayName = 'Alert';
|
|
965
|
+
|
|
966
|
+
const AlertTitle = React.forwardRef<
|
|
967
|
+
HTMLParagraphElement,
|
|
968
|
+
React.HTMLAttributes<HTMLHeadingElement>
|
|
969
|
+
>(({ className, ...props }, ref) => (
|
|
970
|
+
<h5
|
|
971
|
+
ref={ref}
|
|
972
|
+
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
|
|
973
|
+
{...props}
|
|
974
|
+
/>
|
|
975
|
+
));
|
|
976
|
+
AlertTitle.displayName = 'AlertTitle';
|
|
977
|
+
|
|
978
|
+
const AlertDescription = React.forwardRef<
|
|
979
|
+
HTMLParagraphElement,
|
|
980
|
+
React.HTMLAttributes<HTMLParagraphElement>
|
|
981
|
+
>(({ className, ...props }, ref) => (
|
|
982
|
+
<div
|
|
983
|
+
ref={ref}
|
|
984
|
+
className={cn('text-sm [&_p]:leading-relaxed', className)}
|
|
985
|
+
{...props}
|
|
986
|
+
/>
|
|
987
|
+
));
|
|
988
|
+
AlertDescription.displayName = 'AlertDescription';
|
|
989
|
+
|
|
990
|
+
export { Alert, AlertTitle, AlertDescription };
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
---
|
|
994
|
+
|
|
995
|
+
## Task 7: Update Dependencies and Install react-hook-form
|
|
996
|
+
|
|
997
|
+
**Files:**
|
|
998
|
+
- Modify: `host/package.json`
|
|
999
|
+
|
|
1000
|
+
### Step 1: Add missing dependencies
|
|
1001
|
+
|
|
1002
|
+
**File:** `host/package.json`
|
|
1003
|
+
|
|
1004
|
+
Add to dependencies:
|
|
1005
|
+
```json
|
|
1006
|
+
{
|
|
1007
|
+
"dependencies": {
|
|
1008
|
+
"@hookform/resolvers": "^3.9.0",
|
|
1009
|
+
"react-hook-form": "^7.53.0"
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
```
|
|
1013
|
+
|
|
1014
|
+
### Step 2: Install new dependencies
|
|
1015
|
+
|
|
1016
|
+
**Run:** From root directory
|
|
1017
|
+
|
|
1018
|
+
```bash
|
|
1019
|
+
pnpm install
|
|
1020
|
+
```
|
|
1021
|
+
|
|
1022
|
+
---
|
|
1023
|
+
|
|
1024
|
+
## Task 8: Create Logout Functionality
|
|
1025
|
+
|
|
1026
|
+
**Files:**
|
|
1027
|
+
- Create: `host/src/kernel/auth/components/LogoutButton.tsx`
|
|
1028
|
+
- Modify: `host/src/layout/Topbar.tsx`
|
|
1029
|
+
|
|
1030
|
+
### Step 1: Create logout button component
|
|
1031
|
+
|
|
1032
|
+
**File:** `host/src/kernel/auth/components/LogoutButton.tsx`
|
|
1033
|
+
|
|
1034
|
+
```typescript
|
|
1035
|
+
import { useState } from 'react';
|
|
1036
|
+
import { useNavigate } from '@modern-js/runtime/router';
|
|
1037
|
+
import { useAuth } from '../hooks';
|
|
1038
|
+
import { Button } from '../../components/ui/button';
|
|
1039
|
+
import {
|
|
1040
|
+
DropdownMenu,
|
|
1041
|
+
DropdownMenuContent,
|
|
1042
|
+
DropdownMenuItem,
|
|
1043
|
+
DropdownMenuLabel,
|
|
1044
|
+
DropdownMenuSeparator,
|
|
1045
|
+
DropdownMenuTrigger,
|
|
1046
|
+
} from '../../components/ui/dropdown-menu';
|
|
1047
|
+
import { Loader2, LogOut, Settings, User } from 'lucide-react';
|
|
1048
|
+
import { getInitials } from '../../lib/utils';
|
|
1049
|
+
import { useGlobalKernelState } from '../../shared-state';
|
|
1050
|
+
|
|
1051
|
+
export function LogoutButton() {
|
|
1052
|
+
const { logout, isLoading } = useAuth();
|
|
1053
|
+
const navigate = useNavigate();
|
|
1054
|
+
const { user } = useGlobalKernelState();
|
|
1055
|
+
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
|
1056
|
+
|
|
1057
|
+
const handleLogout = async () => {
|
|
1058
|
+
setIsLoggingOut(true);
|
|
1059
|
+
try {
|
|
1060
|
+
await logout();
|
|
1061
|
+
navigate('/login');
|
|
1062
|
+
} finally {
|
|
1063
|
+
setIsLoggingOut(false);
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
return (
|
|
1068
|
+
<DropdownMenu>
|
|
1069
|
+
<DropdownMenuTrigger asChild>
|
|
1070
|
+
<Button variant="ghost" className="relative h-9 w-9 rounded-full">
|
|
1071
|
+
{user?.name ? (
|
|
1072
|
+
<span className="flex h-full w-full items-center justify-center bg-primary text-primary-foreground text-sm font-medium">
|
|
1073
|
+
{getInitials(user.name)}
|
|
1074
|
+
</span>
|
|
1075
|
+
) : (
|
|
1076
|
+
<User className="h-5 w-5" />
|
|
1077
|
+
)}
|
|
1078
|
+
</Button>
|
|
1079
|
+
</DropdownMenuTrigger>
|
|
1080
|
+
<DropdownMenuContent className="w-56" align="end" forceMount>
|
|
1081
|
+
<DropdownMenuLabel className="font-normal">
|
|
1082
|
+
<div className="flex flex-col space-y-1">
|
|
1083
|
+
<p className="text-sm font-medium leading-none">{user?.name}</p>
|
|
1084
|
+
<p className="text-xs leading-none text-muted-foreground">
|
|
1085
|
+
{user?.email}
|
|
1086
|
+
</p>
|
|
1087
|
+
</div>
|
|
1088
|
+
</DropdownMenuLabel>
|
|
1089
|
+
<DropdownMenuSeparator />
|
|
1090
|
+
<DropdownMenuItem onClick={() => navigate('/settings/profile')}>
|
|
1091
|
+
<User className="mr-2 h-4 w-4" />
|
|
1092
|
+
<span>Profile</span>
|
|
1093
|
+
</DropdownMenuItem>
|
|
1094
|
+
<DropdownMenuItem onClick={() => navigate('/settings')}>
|
|
1095
|
+
<Settings className="mr-2 h-4 w-4" />
|
|
1096
|
+
<span>Settings</span>
|
|
1097
|
+
</DropdownMenuItem>
|
|
1098
|
+
<DropdownMenuSeparator />
|
|
1099
|
+
<DropdownMenuItem
|
|
1100
|
+
onClick={handleLogout}
|
|
1101
|
+
disabled={isLoading || isLoggingOut}
|
|
1102
|
+
>
|
|
1103
|
+
{isLoggingOut ? (
|
|
1104
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
1105
|
+
) : (
|
|
1106
|
+
<LogOut className="mr-2 h-4 w-4" />
|
|
1107
|
+
)}
|
|
1108
|
+
<span>Log out</span>
|
|
1109
|
+
</DropdownMenuItem>
|
|
1110
|
+
</DropdownMenuContent>
|
|
1111
|
+
</DropdownMenu>
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
```
|
|
1115
|
+
|
|
1116
|
+
### Step 2: Create dropdown menu component
|
|
1117
|
+
|
|
1118
|
+
**File:** `host/src/kernel/components/ui/dropdown-menu.tsx`
|
|
1119
|
+
|
|
1120
|
+
```typescript
|
|
1121
|
+
import * as React from 'react';
|
|
1122
|
+
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
|
1123
|
+
import { Check, ChevronRight, Circle } from 'lucide-react';
|
|
1124
|
+
import { cn } from '../../../lib/utils';
|
|
1125
|
+
|
|
1126
|
+
const DropdownMenu = DropdownMenuPrimitive.Root;
|
|
1127
|
+
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
|
1128
|
+
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
|
1129
|
+
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
|
|
1130
|
+
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
|
1131
|
+
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
|
1132
|
+
|
|
1133
|
+
const DropdownMenuSubTrigger = React.forwardRef<
|
|
1134
|
+
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
|
1135
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
|
1136
|
+
inset?: boolean;
|
|
1137
|
+
}
|
|
1138
|
+
>(({ className, inset, children, ...props }, ref) => (
|
|
1139
|
+
<DropdownMenuPrimitive.SubTrigger
|
|
1140
|
+
ref={ref}
|
|
1141
|
+
className={cn(
|
|
1142
|
+
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
|
|
1143
|
+
inset && 'pl-8',
|
|
1144
|
+
className
|
|
1145
|
+
)}
|
|
1146
|
+
{...props}
|
|
1147
|
+
>
|
|
1148
|
+
{children}
|
|
1149
|
+
<ChevronRight className="ml-auto h-4 w-4" />
|
|
1150
|
+
</DropdownMenuPrimitive.SubTrigger>
|
|
1151
|
+
));
|
|
1152
|
+
DropdownMenuSubTrigger.displayName =
|
|
1153
|
+
DropdownMenuPrimitive.SubTrigger.displayName;
|
|
1154
|
+
|
|
1155
|
+
const DropdownMenuSubContent = React.forwardRef<
|
|
1156
|
+
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
|
1157
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
|
1158
|
+
>(({ className, ...props }, ref) => (
|
|
1159
|
+
<DropdownMenuPrimitive.SubContent
|
|
1160
|
+
ref={ref}
|
|
1161
|
+
className={cn(
|
|
1162
|
+
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-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',
|
|
1163
|
+
className
|
|
1164
|
+
)}
|
|
1165
|
+
{...props}
|
|
1166
|
+
/>
|
|
1167
|
+
));
|
|
1168
|
+
DropdownMenuSubContent.displayName =
|
|
1169
|
+
DropdownMenuPrimitive.SubContent.displayName;
|
|
1170
|
+
|
|
1171
|
+
const DropdownMenuContent = React.forwardRef<
|
|
1172
|
+
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
|
1173
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
|
1174
|
+
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
1175
|
+
<DropdownMenuPrimitive.Portal>
|
|
1176
|
+
<DropdownMenuPrimitive.Content
|
|
1177
|
+
ref={ref}
|
|
1178
|
+
sideOffset={sideOffset}
|
|
1179
|
+
className={cn(
|
|
1180
|
+
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-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',
|
|
1181
|
+
className
|
|
1182
|
+
)}
|
|
1183
|
+
{...props}
|
|
1184
|
+
/>
|
|
1185
|
+
</DropdownMenuPrimitive.Portal>
|
|
1186
|
+
));
|
|
1187
|
+
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
|
1188
|
+
|
|
1189
|
+
const DropdownMenuItem = React.forwardRef<
|
|
1190
|
+
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
|
1191
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
|
1192
|
+
inset?: boolean;
|
|
1193
|
+
}
|
|
1194
|
+
>(({ className, inset, ...props }, ref) => (
|
|
1195
|
+
<DropdownMenuPrimitive.Item
|
|
1196
|
+
ref={ref}
|
|
1197
|
+
className={cn(
|
|
1198
|
+
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
1199
|
+
inset && 'pl-8',
|
|
1200
|
+
className
|
|
1201
|
+
)}
|
|
1202
|
+
{...props}
|
|
1203
|
+
/>
|
|
1204
|
+
));
|
|
1205
|
+
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
|
1206
|
+
|
|
1207
|
+
const DropdownMenuCheckboxItem = React.forwardRef<
|
|
1208
|
+
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
|
1209
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
|
1210
|
+
>(({ className, children, checked, ...props }, ref) => (
|
|
1211
|
+
<DropdownMenuPrimitive.CheckboxItem
|
|
1212
|
+
ref={ref}
|
|
1213
|
+
className={cn(
|
|
1214
|
+
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
1215
|
+
className
|
|
1216
|
+
)}
|
|
1217
|
+
checked={checked}
|
|
1218
|
+
{...props}
|
|
1219
|
+
>
|
|
1220
|
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
1221
|
+
<DropdownMenuPrimitive.ItemIndicator>
|
|
1222
|
+
<Check className="h-4 w-4" />
|
|
1223
|
+
</DropdownMenuPrimitive.ItemIndicator>
|
|
1224
|
+
</span>
|
|
1225
|
+
{children}
|
|
1226
|
+
</DropdownMenuPrimitive.CheckboxItem>
|
|
1227
|
+
));
|
|
1228
|
+
DropdownMenuCheckboxItem.displayName =
|
|
1229
|
+
DropdownMenuPrimitive.CheckboxItem.displayName;
|
|
1230
|
+
|
|
1231
|
+
const DropdownMenuRadioItem = React.forwardRef<
|
|
1232
|
+
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
|
1233
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
|
1234
|
+
>(({ className, children, ...props }, ref) => (
|
|
1235
|
+
<DropdownMenuPrimitive.RadioItem
|
|
1236
|
+
ref={ref}
|
|
1237
|
+
className={cn(
|
|
1238
|
+
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
|
1239
|
+
className
|
|
1240
|
+
)}
|
|
1241
|
+
{...props}
|
|
1242
|
+
>
|
|
1243
|
+
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
|
1244
|
+
<DropdownMenuPrimitive.ItemIndicator>
|
|
1245
|
+
<Circle className="h-2 w-2 fill-current" />
|
|
1246
|
+
</DropdownMenuPrimitive.ItemIndicator>
|
|
1247
|
+
</span>
|
|
1248
|
+
{children}
|
|
1249
|
+
</DropdownMenuPrimitive.RadioItem>
|
|
1250
|
+
));
|
|
1251
|
+
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
|
|
1252
|
+
|
|
1253
|
+
const DropdownMenuLabel = React.forwardRef<
|
|
1254
|
+
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
|
1255
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
|
1256
|
+
inset?: boolean;
|
|
1257
|
+
}
|
|
1258
|
+
>(({ className, inset, ...props }, ref) => (
|
|
1259
|
+
<DropdownMenuPrimitive.Label
|
|
1260
|
+
ref={ref}
|
|
1261
|
+
className={cn(
|
|
1262
|
+
'px-2 py-1.5 text-sm font-semibold',
|
|
1263
|
+
inset && 'pl-8',
|
|
1264
|
+
className
|
|
1265
|
+
)}
|
|
1266
|
+
{...props}
|
|
1267
|
+
/>
|
|
1268
|
+
));
|
|
1269
|
+
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
|
|
1270
|
+
|
|
1271
|
+
const DropdownMenuSeparator = React.forwardRef<
|
|
1272
|
+
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
|
1273
|
+
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
|
1274
|
+
>(({ className, ...props }, ref) => (
|
|
1275
|
+
<DropdownMenuPrimitive.Separator
|
|
1276
|
+
ref={ref}
|
|
1277
|
+
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
|
1278
|
+
{...props}
|
|
1279
|
+
/>
|
|
1280
|
+
));
|
|
1281
|
+
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
|
1282
|
+
|
|
1283
|
+
const DropdownMenuShortcut = ({
|
|
1284
|
+
className,
|
|
1285
|
+
...props
|
|
1286
|
+
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
|
1287
|
+
return (
|
|
1288
|
+
<span
|
|
1289
|
+
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
|
|
1290
|
+
{...props}
|
|
1291
|
+
/>
|
|
1292
|
+
);
|
|
1293
|
+
};
|
|
1294
|
+
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
|
|
1295
|
+
|
|
1296
|
+
export {
|
|
1297
|
+
DropdownMenu,
|
|
1298
|
+
DropdownMenuTrigger,
|
|
1299
|
+
DropdownMenuContent,
|
|
1300
|
+
DropdownMenuItem,
|
|
1301
|
+
DropdownMenuCheckboxItem,
|
|
1302
|
+
DropdownMenuRadioItem,
|
|
1303
|
+
DropdownMenuLabel,
|
|
1304
|
+
DropdownMenuSeparator,
|
|
1305
|
+
DropdownMenuShortcut,
|
|
1306
|
+
DropdownMenuGroup,
|
|
1307
|
+
DropdownMenuPortal,
|
|
1308
|
+
DropdownMenuSub,
|
|
1309
|
+
DropdownMenuSubContent,
|
|
1310
|
+
DropdownMenuSubTrigger,
|
|
1311
|
+
DropdownMenuRadioGroup,
|
|
1312
|
+
};
|
|
1313
|
+
```
|
|
1314
|
+
|
|
1315
|
+
### Step 3: Update topbar with logout button
|
|
1316
|
+
|
|
1317
|
+
**File:** `host/src/layout/Topbar.tsx`
|
|
1318
|
+
|
|
1319
|
+
```typescript
|
|
1320
|
+
import { useGlobalKernelState } from '../kernel/shared-state';
|
|
1321
|
+
import { Menu, Bell } from 'lucide-react';
|
|
1322
|
+
import { LogoutButton } from '../kernel/auth/components/LogoutButton';
|
|
1323
|
+
import { useAuth } from '../kernel/auth/hooks';
|
|
1324
|
+
|
|
1325
|
+
export function Topbar() {
|
|
1326
|
+
const { toggleMobileMenu } = useGlobalKernelState();
|
|
1327
|
+
const { isAuthenticated } = useAuth();
|
|
1328
|
+
|
|
1329
|
+
return (
|
|
1330
|
+
<header className="sticky top-0 z-20 flex h-16 items-center gap-4 border-b bg-background px-6">
|
|
1331
|
+
{/* Mobile menu button */}
|
|
1332
|
+
<button
|
|
1333
|
+
onClick={toggleMobileMenu}
|
|
1334
|
+
className="lg:hidden rounded-lg p-2 text-muted-foreground hover:bg-muted"
|
|
1335
|
+
>
|
|
1336
|
+
<Menu className="h-5 w-5" />
|
|
1337
|
+
</button>
|
|
1338
|
+
|
|
1339
|
+
{/* Breadcrumb/spacer */}
|
|
1340
|
+
<div className="flex-1" />
|
|
1341
|
+
|
|
1342
|
+
{/* Actions */}
|
|
1343
|
+
<div className="flex items-center gap-2">
|
|
1344
|
+
{isAuthenticated && (
|
|
1345
|
+
<button className="rounded-lg p-2 text-muted-foreground hover:bg-muted">
|
|
1346
|
+
<Bell className="h-5 w-5" />
|
|
1347
|
+
</button>
|
|
1348
|
+
)}
|
|
1349
|
+
|
|
1350
|
+
{isAuthenticated && <LogoutButton />}
|
|
1351
|
+
</div>
|
|
1352
|
+
</header>
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
```
|
|
1356
|
+
|
|
1357
|
+
---
|
|
1358
|
+
|
|
1359
|
+
## Task 9: Create Auth Module Barrel Export
|
|
1360
|
+
|
|
1361
|
+
**Files:**
|
|
1362
|
+
- Create: `host/src/kernel/auth/index.ts`
|
|
1363
|
+
|
|
1364
|
+
### Step 1: Create barrel export
|
|
1365
|
+
|
|
1366
|
+
**File:** `host/src/kernel/auth/index.ts`
|
|
1367
|
+
|
|
1368
|
+
```typescript
|
|
1369
|
+
export * from './types';
|
|
1370
|
+
export * from './schemas';
|
|
1371
|
+
export * from './service';
|
|
1372
|
+
export * from './hooks';
|
|
1373
|
+
export * from './ProtectedRoute';
|
|
1374
|
+
```
|
|
1375
|
+
|
|
1376
|
+
---
|
|
1377
|
+
|
|
1378
|
+
## Verification
|
|
1379
|
+
|
|
1380
|
+
### Step 1: Build the host
|
|
1381
|
+
|
|
1382
|
+
**Run:**
|
|
1383
|
+
|
|
1384
|
+
```bash
|
|
1385
|
+
cd host
|
|
1386
|
+
pnpm run build
|
|
1387
|
+
```
|
|
1388
|
+
|
|
1389
|
+
Expected: Build completes without errors.
|
|
1390
|
+
|
|
1391
|
+
### Step 2: Start development server
|
|
1392
|
+
|
|
1393
|
+
**Run:**
|
|
1394
|
+
|
|
1395
|
+
```bash
|
|
1396
|
+
cd host
|
|
1397
|
+
pnpm run dev
|
|
1398
|
+
```
|
|
1399
|
+
|
|
1400
|
+
Expected: Server starts on http://localhost:8080
|
|
1401
|
+
|
|
1402
|
+
### Step 3: Test login flow
|
|
1403
|
+
|
|
1404
|
+
1. Open http://localhost:8080
|
|
1405
|
+
2. Click "Sign In" button
|
|
1406
|
+
3. Enter credentials: `admin@example.com` / `admin123`
|
|
1407
|
+
4. Submit form
|
|
1408
|
+
5. Should redirect to `/dashboard`
|
|
1409
|
+
6. Verify logout button appears in topbar with user avatar
|
|
1410
|
+
7. Click logout button → "Log out"
|
|
1411
|
+
8. Should redirect back to `/login`
|
|
1412
|
+
|
|
1413
|
+
### Step 4: Test protected routes
|
|
1414
|
+
|
|
1415
|
+
1. Try accessing http://localhost:8080/dashboard while logged out
|
|
1416
|
+
2. Should redirect to `/login`
|
|
1417
|
+
3. After login, should access dashboard successfully
|
|
1418
|
+
|
|
1419
|
+
---
|
|
1420
|
+
|
|
1421
|
+
## Summary
|
|
1422
|
+
|
|
1423
|
+
After completing this document, you will have:
|
|
1424
|
+
|
|
1425
|
+
1. ✅ Complete authentication service with PocketBase integration
|
|
1426
|
+
2. ✅ Login page with form validation using Zod + react-hook-form
|
|
1427
|
+
3. ✅ Protected route component for guarding authenticated pages
|
|
1428
|
+
4. ✅ Auth hooks: `useAuth`, `useCurrentUser`, `useRequireAuth`
|
|
1429
|
+
5. ✅ Logout functionality with dropdown menu
|
|
1430
|
+
6. ✅ Session persistence via PocketBase auth store
|
|
1431
|
+
7. ✅ Error handling for auth failures
|
|
1432
|
+
8. ✅ All required Shadcn UI components (Button, Input, Label, Card, Alert, DropdownMenu)
|
|
1433
|
+
|
|
1434
|
+
**Next:** `05-multitenancy-rbac.md` - Implement organizations, users management, roles, and permissions system.
|
|
1435
|
+
|
|
1436
|
+
---
|
|
1437
|
+
|
|
1438
|
+
## Files Created
|
|
1439
|
+
|
|
1440
|
+
```
|
|
1441
|
+
host/
|
|
1442
|
+
└── src/
|
|
1443
|
+
└── kernel/
|
|
1444
|
+
├── auth/
|
|
1445
|
+
│ ├── types.ts
|
|
1446
|
+
│ ├── schemas.ts
|
|
1447
|
+
│ ├── service.ts
|
|
1448
|
+
│ ├── hooks.ts
|
|
1449
|
+
│ ├── ProtectedRoute.tsx
|
|
1450
|
+
│ ├── components/
|
|
1451
|
+
│ │ └── LoginForm.tsx
|
|
1452
|
+
│ └── index.ts
|
|
1453
|
+
├── components/
|
|
1454
|
+
│ └── ui/
|
|
1455
|
+
│ ├── button.tsx
|
|
1456
|
+
│ ├── input.tsx
|
|
1457
|
+
│ ├── label.tsx
|
|
1458
|
+
│ ├── card.tsx
|
|
1459
|
+
│ ├── alert.tsx
|
|
1460
|
+
│ └── dropdown-menu.tsx
|
|
1461
|
+
└── layout/
|
|
1462
|
+
└── Topbar.tsx (modified)
|
|
1463
|
+
|
|
1464
|
+
host/src/routes/
|
|
1465
|
+
└── login.tsx
|
|
1466
|
+
```
|