mvc-kit 2.13.0 → 2.13.2
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/BEST_PRACTICES.md +1390 -0
- package/agent-config/claude-code/agents/mvc-kit-architect.md +8 -3
- package/agent-config/claude-code/skills/{guide → mvc-kit}/SKILL.md +10 -1
- package/agent-config/lib/install-claude.mjs +39 -110
- package/examples/primitive/channel.ts +109 -0
- package/examples/primitive/collection.ts +118 -0
- package/examples/primitive/controller.ts +118 -0
- package/examples/primitive/counter.ts +108 -0
- package/examples/primitive/env.d.ts +1 -0
- package/examples/primitive/eventbus.ts +77 -0
- package/examples/primitive/feed.ts +162 -0
- package/examples/primitive/model.ts +82 -0
- package/examples/primitive/pagination.ts +91 -0
- package/examples/primitive/pending.ts +189 -0
- package/examples/primitive/persistent-collection.ts +116 -0
- package/examples/primitive/resource.ts +114 -0
- package/examples/primitive/selection.ts +96 -0
- package/examples/primitive/sorting.ts +112 -0
- package/examples/primitive/timer.ts +58 -0
- package/examples/primitive/trackable.ts +225 -0
- package/examples/primitive/tsconfig.json +20 -0
- package/examples/primitive/viewmodel-service.ts +161 -0
- package/examples/react/AuthExample/index.html +12 -0
- package/examples/react/AuthExample/src/App.tsx +29 -0
- package/examples/react/AuthExample/src/components/AdminPage.tsx +51 -0
- package/examples/react/AuthExample/src/components/AppHeader.tsx +32 -0
- package/examples/react/AuthExample/src/components/AuthGuard.tsx +50 -0
- package/examples/react/AuthExample/src/components/AuthScreen.tsx +181 -0
- package/examples/react/AuthExample/src/components/DashboardPage.tsx +41 -0
- package/examples/react/AuthExample/src/components/ProfilePage.tsx +44 -0
- package/examples/react/AuthExample/src/components/Toast.tsx +41 -0
- package/examples/react/AuthExample/src/env.d.ts +10 -0
- package/examples/react/AuthExample/src/events/AppEventBus.ts +7 -0
- package/examples/react/AuthExample/src/main.tsx +10 -0
- package/examples/react/AuthExample/src/mock/api.ts +78 -0
- package/examples/react/AuthExample/src/models/LoginFormModel.ts +19 -0
- package/examples/react/AuthExample/src/models/RegisterFormModel.ts +25 -0
- package/examples/react/AuthExample/src/services/AuthService.ts +21 -0
- package/examples/react/AuthExample/src/styles.css +445 -0
- package/examples/react/AuthExample/src/types/auth.ts +12 -0
- package/examples/react/AuthExample/src/viewmodels/AuthViewModel.ts +111 -0
- package/examples/react/AuthExample/tsconfig.json +22 -0
- package/examples/react/AuthExample/vite.config.ts +18 -0
- package/examples/react/ComplexApp/index.html +12 -0
- package/examples/react/ComplexApp/src/App.tsx +17 -0
- package/examples/react/ComplexApp/src/channels/ActivityChannel.ts +24 -0
- package/examples/react/ComplexApp/src/channels/DashboardChannel.ts +26 -0
- package/examples/react/ComplexApp/src/channels/ErrorsChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/LatencyChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/OrdersChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/RevenueChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/TrafficChannel.ts +5 -0
- package/examples/react/ComplexApp/src/channels/UsersMetricChannel.ts +5 -0
- package/examples/react/ComplexApp/src/collections/DashboardCollection.ts +6 -0
- package/examples/react/ComplexApp/src/collections/ErrorsCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/LatencyCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/OrdersCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/RevenueCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/TrafficCollection.ts +3 -0
- package/examples/react/ComplexApp/src/collections/UsersMetricCollection.ts +3 -0
- package/examples/react/ComplexApp/src/components/activity/ActivityFeed.tsx +31 -0
- package/examples/react/ComplexApp/src/components/activity/ActivityItemRow.tsx +35 -0
- package/examples/react/ComplexApp/src/components/dashboard/DashboardCard.tsx +37 -0
- package/examples/react/ComplexApp/src/components/dashboard/DashboardPage.tsx +34 -0
- package/examples/react/ComplexApp/src/components/layout/Navbar.tsx +32 -0
- package/examples/react/ComplexApp/src/components/layout/SocialFeedPanel.tsx +57 -0
- package/examples/react/ComplexApp/src/components/shared/Spinner.tsx +3 -0
- package/examples/react/ComplexApp/src/components/shared/StatusIndicator.tsx +13 -0
- package/examples/react/ComplexApp/src/components/shared/Toast.tsx +40 -0
- package/examples/react/ComplexApp/src/env.d.ts +10 -0
- package/examples/react/ComplexApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/ComplexApp/src/main.tsx +10 -0
- package/examples/react/ComplexApp/src/mock-remote/MockWebSocket.ts +38 -0
- package/examples/react/ComplexApp/src/mock-remote/activity-api.ts +48 -0
- package/examples/react/ComplexApp/src/mock-remote/dashboard-generators.ts +45 -0
- package/examples/react/ComplexApp/src/mock-remote/delay.ts +18 -0
- package/examples/react/ComplexApp/src/mock-remote/social-api.ts +55 -0
- package/examples/react/ComplexApp/src/resources/ActivityResource.ts +12 -0
- package/examples/react/ComplexApp/src/resources/SocialFeedResource.ts +17 -0
- package/examples/react/ComplexApp/src/styles.css +463 -0
- package/examples/react/ComplexApp/src/types/activity.ts +8 -0
- package/examples/react/ComplexApp/src/types/dashboard.ts +5 -0
- package/examples/react/ComplexApp/src/types/social.ts +8 -0
- package/examples/react/ComplexApp/src/types/users.ts +6 -0
- package/examples/react/ComplexApp/src/viewmodels/ActivityFeedViewModel.ts +68 -0
- package/examples/react/ComplexApp/src/viewmodels/AppStateViewModel.ts +26 -0
- package/examples/react/ComplexApp/src/viewmodels/DashboardCardViewModel.ts +69 -0
- package/examples/react/ComplexApp/src/viewmodels/ErrorsCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/LatencyCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/OrdersCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/RevenueCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/SocialFeedViewModel.ts +39 -0
- package/examples/react/ComplexApp/src/viewmodels/TrafficCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/src/viewmodels/UsersMetricCardViewModel.ts +9 -0
- package/examples/react/ComplexApp/tsconfig.json +22 -0
- package/examples/react/ComplexApp/vite.config.ts +18 -0
- package/examples/react/FullApp/index.html +12 -0
- package/examples/react/FullApp/src/App.tsx +28 -0
- package/examples/react/FullApp/src/collections/ConversationsCollection.ts +4 -0
- package/examples/react/FullApp/src/collections/LocationsCollection.ts +4 -0
- package/examples/react/FullApp/src/components/auth/LoginPage.tsx +80 -0
- package/examples/react/FullApp/src/components/dashboard/DashboardPage.tsx +29 -0
- package/examples/react/FullApp/src/components/dashboard/RecentActivityCard.tsx +35 -0
- package/examples/react/FullApp/src/components/dashboard/StatsCard.tsx +19 -0
- package/examples/react/FullApp/src/components/layout/AppShell.tsx +31 -0
- package/examples/react/FullApp/src/components/layout/Header.tsx +25 -0
- package/examples/react/FullApp/src/components/layout/Sidebar.tsx +29 -0
- package/examples/react/FullApp/src/components/locations/LocationFilters.tsx +60 -0
- package/examples/react/FullApp/src/components/locations/LocationForm.tsx +112 -0
- package/examples/react/FullApp/src/components/locations/LocationProfilePage.tsx +81 -0
- package/examples/react/FullApp/src/components/locations/LocationsPage.tsx +127 -0
- package/examples/react/FullApp/src/components/messaging/ConversationList.tsx +59 -0
- package/examples/react/FullApp/src/components/messaging/MessageBubble.tsx +22 -0
- package/examples/react/FullApp/src/components/messaging/MessageThread.tsx +100 -0
- package/examples/react/FullApp/src/components/messaging/MessagingPage.tsx +52 -0
- package/examples/react/FullApp/src/components/shared/ErrorBanner.tsx +3 -0
- package/examples/react/FullApp/src/components/shared/Spinner.tsx +7 -0
- package/examples/react/FullApp/src/components/shared/Toast.tsx +41 -0
- package/examples/react/FullApp/src/components/users/UserFilters.tsx +59 -0
- package/examples/react/FullApp/src/components/users/UsersPage.tsx +80 -0
- package/examples/react/FullApp/src/components/users/UsersTable.tsx +52 -0
- package/examples/react/FullApp/src/env.d.ts +10 -0
- package/examples/react/FullApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/FullApp/src/main.tsx +10 -0
- package/examples/react/FullApp/src/mock/delay.ts +21 -0
- package/examples/react/FullApp/src/mock/locations.ts +76 -0
- package/examples/react/FullApp/src/mock/messages.ts +237 -0
- package/examples/react/FullApp/src/mock/users.ts +84 -0
- package/examples/react/FullApp/src/models/LocationFormModel.ts +31 -0
- package/examples/react/FullApp/src/models/LoginFormModel.ts +19 -0
- package/examples/react/FullApp/src/resources/UsersResource.ts +12 -0
- package/examples/react/FullApp/src/services/AuthService.ts +18 -0
- package/examples/react/FullApp/src/services/LocationService.ts +23 -0
- package/examples/react/FullApp/src/services/MessageService.ts +65 -0
- package/examples/react/FullApp/src/services/UserService.ts +23 -0
- package/examples/react/FullApp/src/styles.css +767 -0
- package/examples/react/FullApp/src/types/conversation.ts +7 -0
- package/examples/react/FullApp/src/types/location.ts +12 -0
- package/examples/react/FullApp/src/types/message.ts +7 -0
- package/examples/react/FullApp/src/types/user.ts +10 -0
- package/examples/react/FullApp/src/viewmodels/AuthViewModel.ts +51 -0
- package/examples/react/FullApp/src/viewmodels/ConversationsViewModel.ts +89 -0
- package/examples/react/FullApp/src/viewmodels/DashboardViewModel.ts +56 -0
- package/examples/react/FullApp/src/viewmodels/LocationProfileViewModel.ts +81 -0
- package/examples/react/FullApp/src/viewmodels/LocationsViewModel.ts +113 -0
- package/examples/react/FullApp/src/viewmodels/MessageThreadViewModel.ts +83 -0
- package/examples/react/FullApp/src/viewmodels/UsersViewModel.ts +88 -0
- package/examples/react/FullApp/tsconfig.json +22 -0
- package/examples/react/FullApp/vite.config.ts +18 -0
- package/examples/react/WorkerApp/index.html +12 -0
- package/examples/react/WorkerApp/src/App.tsx +24 -0
- package/examples/react/WorkerApp/src/channels/MessagingChannel.ts +46 -0
- package/examples/react/WorkerApp/src/channels/WorkerStatusChannel.ts +35 -0
- package/examples/react/WorkerApp/src/components/auth/LoginPage.tsx +60 -0
- package/examples/react/WorkerApp/src/components/layout/AppShell.tsx +31 -0
- package/examples/react/WorkerApp/src/components/layout/Header.tsx +23 -0
- package/examples/react/WorkerApp/src/components/layout/Sidebar.tsx +28 -0
- package/examples/react/WorkerApp/src/components/messaging/ComposeBar.tsx +33 -0
- package/examples/react/WorkerApp/src/components/messaging/ConversationList.tsx +59 -0
- package/examples/react/WorkerApp/src/components/messaging/MessageBubble.tsx +45 -0
- package/examples/react/WorkerApp/src/components/messaging/MessageThread.tsx +93 -0
- package/examples/react/WorkerApp/src/components/messaging/MessagingPage.tsx +53 -0
- package/examples/react/WorkerApp/src/components/shared/ErrorBanner.tsx +3 -0
- package/examples/react/WorkerApp/src/components/shared/PendingBanner.tsx +37 -0
- package/examples/react/WorkerApp/src/components/shared/Spinner.tsx +7 -0
- package/examples/react/WorkerApp/src/components/shared/Toast.tsx +41 -0
- package/examples/react/WorkerApp/src/components/shift/ShiftPage.tsx +98 -0
- package/examples/react/WorkerApp/src/components/shift/ShiftTimer.tsx +24 -0
- package/examples/react/WorkerApp/src/components/shift/SiteSelector.tsx +27 -0
- package/examples/react/WorkerApp/src/components/sites/SiteFilters.tsx +61 -0
- package/examples/react/WorkerApp/src/components/sites/SitesPage.tsx +102 -0
- package/examples/react/WorkerApp/src/env.d.ts +10 -0
- package/examples/react/WorkerApp/src/events/AppEventBus.ts +7 -0
- package/examples/react/WorkerApp/src/main.tsx +10 -0
- package/examples/react/WorkerApp/src/mock/MockWebSocket.ts +38 -0
- package/examples/react/WorkerApp/src/mock/delay.ts +31 -0
- package/examples/react/WorkerApp/src/mock/messages.ts +120 -0
- package/examples/react/WorkerApp/src/mock/shifts.ts +57 -0
- package/examples/react/WorkerApp/src/mock/sites.ts +14 -0
- package/examples/react/WorkerApp/src/mock/workers.ts +12 -0
- package/examples/react/WorkerApp/src/models/ComposeMessageModel.ts +17 -0
- package/examples/react/WorkerApp/src/resources/ConversationsResource.ts +10 -0
- package/examples/react/WorkerApp/src/resources/MessagesResource.ts +32 -0
- package/examples/react/WorkerApp/src/resources/ShiftResource.ts +73 -0
- package/examples/react/WorkerApp/src/resources/SitesResource.ts +11 -0
- package/examples/react/WorkerApp/src/resources/WorkersResource.ts +11 -0
- package/examples/react/WorkerApp/src/styles.css +756 -0
- package/examples/react/WorkerApp/src/types/conversation.ts +7 -0
- package/examples/react/WorkerApp/src/types/message.ts +7 -0
- package/examples/react/WorkerApp/src/types/shift.ts +13 -0
- package/examples/react/WorkerApp/src/types/site.ts +8 -0
- package/examples/react/WorkerApp/src/types/worker.ts +8 -0
- package/examples/react/WorkerApp/src/viewmodels/AuthViewModel.ts +41 -0
- package/examples/react/WorkerApp/src/viewmodels/ConversationsViewModel.ts +83 -0
- package/examples/react/WorkerApp/src/viewmodels/MessageThreadViewModel.ts +113 -0
- package/examples/react/WorkerApp/src/viewmodels/ShiftViewModel.ts +147 -0
- package/examples/react/WorkerApp/src/viewmodels/SitesViewModel.ts +82 -0
- package/examples/react/WorkerApp/tsconfig.json +22 -0
- package/examples/react/WorkerApp/vite.config.ts +18 -0
- package/package.json +4 -2
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/anti-patterns.md +0 -0
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/api-reference.md +0 -0
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/patterns.md +0 -0
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/recipes.md +0 -0
- /package/agent-config/claude-code/skills/{guide → mvc-kit}/testing.md +0 -0
- /package/agent-config/claude-code/skills/{review → mvc-kit-review}/SKILL.md +0 -0
- /package/agent-config/claude-code/skills/{review → mvc-kit-review}/checklist.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/SKILL.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/channel.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/collection.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/controller.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/eventbus.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/model.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/page-component.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/persistent-collection.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/resource.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/service.md +0 -0
- /package/agent-config/claude-code/skills/{scaffold → mvc-kit-scaffold}/templates/viewmodel.md +0 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useSingleton, useModel } from 'mvc-kit/react';
|
|
3
|
+
import { AuthViewModel } from '../viewmodels/AuthViewModel';
|
|
4
|
+
import { LoginFormModel } from '../models/LoginFormModel';
|
|
5
|
+
import { RegisterFormModel } from '../models/RegisterFormModel';
|
|
6
|
+
|
|
7
|
+
type AuthView = 'login' | 'register';
|
|
8
|
+
|
|
9
|
+
export function AuthScreen() {
|
|
10
|
+
// Ephemeral UI state — which form to show. This is acceptable as plain useState
|
|
11
|
+
// because it's purely presentational (not domain state).
|
|
12
|
+
const [view, setView] = useState<AuthView>('login');
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className="auth-page">
|
|
16
|
+
<div className="auth-card">
|
|
17
|
+
<h1 className="auth-title">mvc-kit Auth</h1>
|
|
18
|
+
{view === 'login' ? (
|
|
19
|
+
<LoginView onSwitchToRegister={() => setView('register')} />
|
|
20
|
+
) : (
|
|
21
|
+
<RegisterView onSwitchToLogin={() => setView('login')} />
|
|
22
|
+
)}
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function LoginView({ onSwitchToRegister }: { onSwitchToRegister: () => void }) {
|
|
29
|
+
const [, authVM] = useSingleton(AuthViewModel);
|
|
30
|
+
const { loading, error } = authVM.async.login;
|
|
31
|
+
const { state, errors, valid, dirty, model } = useModel(
|
|
32
|
+
() => new LoginFormModel({ email: '', password: '' }),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
if (!valid) return;
|
|
38
|
+
authVM.login(state.email, state.password);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<>
|
|
43
|
+
<p className="auth-subtitle">
|
|
44
|
+
Sign in with <code>admin@example.com</code>, <code>manager@example.com</code>,
|
|
45
|
+
or <code>user@example.com</code> (password: <code>password</code>)
|
|
46
|
+
</p>
|
|
47
|
+
|
|
48
|
+
{error && <div className="error-banner">{error}</div>}
|
|
49
|
+
|
|
50
|
+
<form onSubmit={handleSubmit}>
|
|
51
|
+
<div className="form-group">
|
|
52
|
+
<label className="form-label">Email</label>
|
|
53
|
+
<input
|
|
54
|
+
className={`form-input ${errors.email && dirty ? 'error' : ''}`}
|
|
55
|
+
type="text"
|
|
56
|
+
value={state.email}
|
|
57
|
+
onChange={e => model.setEmail(e.target.value)}
|
|
58
|
+
placeholder="admin@example.com"
|
|
59
|
+
/>
|
|
60
|
+
{errors.email && dirty && <div className="form-error">{errors.email}</div>}
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
<div className="form-group">
|
|
64
|
+
<label className="form-label">Password</label>
|
|
65
|
+
<input
|
|
66
|
+
className={`form-input ${errors.password && dirty ? 'error' : ''}`}
|
|
67
|
+
type="password"
|
|
68
|
+
value={state.password}
|
|
69
|
+
onChange={e => model.setPassword(e.target.value)}
|
|
70
|
+
placeholder="6+ characters"
|
|
71
|
+
/>
|
|
72
|
+
{errors.password && dirty && <div className="form-error">{errors.password}</div>}
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<button
|
|
76
|
+
type="submit"
|
|
77
|
+
className="btn btn-primary"
|
|
78
|
+
style={{ width: '100%', marginTop: '0.5rem' }}
|
|
79
|
+
disabled={!valid || loading}
|
|
80
|
+
>
|
|
81
|
+
{loading ? 'Signing in...' : 'Sign In'}
|
|
82
|
+
</button>
|
|
83
|
+
</form>
|
|
84
|
+
|
|
85
|
+
<p className="auth-toggle">
|
|
86
|
+
Don't have an account?{' '}
|
|
87
|
+
<button type="button" className="link" onClick={onSwitchToRegister}>
|
|
88
|
+
Register
|
|
89
|
+
</button>
|
|
90
|
+
</p>
|
|
91
|
+
</>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function RegisterView({ onSwitchToLogin }: { onSwitchToLogin: () => void }) {
|
|
96
|
+
const [, authVM] = useSingleton(AuthViewModel);
|
|
97
|
+
const { loading, error } = authVM.async.register;
|
|
98
|
+
const { state, errors, valid, dirty, model } = useModel(
|
|
99
|
+
() => new RegisterFormModel({ name: '', email: '', password: '', confirmPassword: '' }),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const handleSubmit = (e: React.FormEvent) => {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
if (!valid) return;
|
|
105
|
+
authVM.register(state.name, state.email, state.password);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<>
|
|
110
|
+
<p className="auth-subtitle">Create a new account</p>
|
|
111
|
+
|
|
112
|
+
{error && <div className="error-banner">{error}</div>}
|
|
113
|
+
|
|
114
|
+
<form onSubmit={handleSubmit}>
|
|
115
|
+
<div className="form-group">
|
|
116
|
+
<label className="form-label">Name</label>
|
|
117
|
+
<input
|
|
118
|
+
className={`form-input ${errors.name && dirty ? 'error' : ''}`}
|
|
119
|
+
type="text"
|
|
120
|
+
value={state.name}
|
|
121
|
+
onChange={e => model.setName(e.target.value)}
|
|
122
|
+
placeholder="Your name"
|
|
123
|
+
/>
|
|
124
|
+
{errors.name && dirty && <div className="form-error">{errors.name}</div>}
|
|
125
|
+
</div>
|
|
126
|
+
|
|
127
|
+
<div className="form-group">
|
|
128
|
+
<label className="form-label">Email</label>
|
|
129
|
+
<input
|
|
130
|
+
className={`form-input ${errors.email && dirty ? 'error' : ''}`}
|
|
131
|
+
type="text"
|
|
132
|
+
value={state.email}
|
|
133
|
+
onChange={e => model.setEmail(e.target.value)}
|
|
134
|
+
placeholder="you@example.com"
|
|
135
|
+
/>
|
|
136
|
+
{errors.email && dirty && <div className="form-error">{errors.email}</div>}
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div className="form-group">
|
|
140
|
+
<label className="form-label">Password</label>
|
|
141
|
+
<input
|
|
142
|
+
className={`form-input ${errors.password && dirty ? 'error' : ''}`}
|
|
143
|
+
type="password"
|
|
144
|
+
value={state.password}
|
|
145
|
+
onChange={e => model.setPassword(e.target.value)}
|
|
146
|
+
placeholder="6+ characters"
|
|
147
|
+
/>
|
|
148
|
+
{errors.password && dirty && <div className="form-error">{errors.password}</div>}
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div className="form-group">
|
|
152
|
+
<label className="form-label">Confirm Password</label>
|
|
153
|
+
<input
|
|
154
|
+
className={`form-input ${errors.confirmPassword && dirty ? 'error' : ''}`}
|
|
155
|
+
type="password"
|
|
156
|
+
value={state.confirmPassword}
|
|
157
|
+
onChange={e => model.setConfirmPassword(e.target.value)}
|
|
158
|
+
placeholder="Repeat password"
|
|
159
|
+
/>
|
|
160
|
+
{errors.confirmPassword && dirty && <div className="form-error">{errors.confirmPassword}</div>}
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<button
|
|
164
|
+
type="submit"
|
|
165
|
+
className="btn btn-primary"
|
|
166
|
+
style={{ width: '100%', marginTop: '0.5rem' }}
|
|
167
|
+
disabled={!valid || loading}
|
|
168
|
+
>
|
|
169
|
+
{loading ? 'Creating account...' : 'Register'}
|
|
170
|
+
</button>
|
|
171
|
+
</form>
|
|
172
|
+
|
|
173
|
+
<p className="auth-toggle">
|
|
174
|
+
Already have an account?{' '}
|
|
175
|
+
<button type="button" className="link" onClick={onSwitchToLogin}>
|
|
176
|
+
Sign in
|
|
177
|
+
</button>
|
|
178
|
+
</p>
|
|
179
|
+
</>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useSingleton } from 'mvc-kit/react';
|
|
2
|
+
import { AuthViewModel } from '../viewmodels/AuthViewModel';
|
|
3
|
+
|
|
4
|
+
export function DashboardPage() {
|
|
5
|
+
const [state, vm] = useSingleton(AuthViewModel);
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<div className="page-content">
|
|
9
|
+
<h1 className="page-title">Dashboard</h1>
|
|
10
|
+
|
|
11
|
+
<div className="card" style={{ marginBottom: '1.5rem' }}>
|
|
12
|
+
<h2 style={{ marginBottom: '0.5rem' }}>Welcome, {vm.displayName}!</h2>
|
|
13
|
+
<p style={{ color: 'var(--color-text-secondary)' }}>
|
|
14
|
+
You are signed in as <strong>{state.user!.email}</strong> with
|
|
15
|
+
the <span className={`badge badge-${state.user!.role}`}>{state.user!.role}</span> role.
|
|
16
|
+
</p>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<div className="stats-grid">
|
|
20
|
+
<div className="stat-card">
|
|
21
|
+
<div className="stat-label">Role</div>
|
|
22
|
+
<div className="stat-value" style={{ fontSize: '1.25rem' }}>{vm.userRole}</div>
|
|
23
|
+
</div>
|
|
24
|
+
<div className="stat-card">
|
|
25
|
+
<div className="stat-label">Admin Access</div>
|
|
26
|
+
<div className="stat-value" style={{ fontSize: '1.25rem' }}>{vm.isAdmin ? 'Yes' : 'No'}</div>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div className="card">
|
|
31
|
+
<h3 style={{ marginBottom: '0.75rem' }}>Auth Pattern Highlights</h3>
|
|
32
|
+
<ul style={{ paddingLeft: '1.25rem', color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
|
33
|
+
<li><code>isAuthenticated</code> is a getter (derived from state), not stored state</li>
|
|
34
|
+
<li>Session restored via <code>onInit()</code>, not useEffect</li>
|
|
35
|
+
<li><code><AuthGuard></code> uses composition — no redirects, URL preserved</li>
|
|
36
|
+
<li>Navigate to <code>/admin</code> to see role-based access (inline, not route-guarded)</li>
|
|
37
|
+
</ul>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useSingleton } from 'mvc-kit/react';
|
|
2
|
+
import { AuthViewModel } from '../viewmodels/AuthViewModel';
|
|
3
|
+
|
|
4
|
+
export function ProfilePage() {
|
|
5
|
+
const [state, vm] = useSingleton(AuthViewModel);
|
|
6
|
+
const user = state.user!;
|
|
7
|
+
|
|
8
|
+
return (
|
|
9
|
+
<div className="page-content">
|
|
10
|
+
<h1 className="page-title">Profile</h1>
|
|
11
|
+
|
|
12
|
+
<div className="card profile-card">
|
|
13
|
+
<div className="profile-header">
|
|
14
|
+
<div className="avatar avatar-lg">{vm.initials}</div>
|
|
15
|
+
<div>
|
|
16
|
+
<h2>{vm.displayName}</h2>
|
|
17
|
+
<p style={{ color: 'var(--color-text-secondary)' }}>{user.email}</p>
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
<div className="profile-details">
|
|
22
|
+
<div className="detail-row">
|
|
23
|
+
<span className="detail-label">Role</span>
|
|
24
|
+
<span className={`badge badge-${user.role}`}>{user.role}</span>
|
|
25
|
+
</div>
|
|
26
|
+
<div className="detail-row">
|
|
27
|
+
<span className="detail-label">Member Since</span>
|
|
28
|
+
<span>{new Date(user.createdAt).toLocaleDateString()}</span>
|
|
29
|
+
</div>
|
|
30
|
+
<div className="detail-row">
|
|
31
|
+
<span className="detail-label">User ID</span>
|
|
32
|
+
<span style={{ fontFamily: 'monospace' }}>{user.id}</span>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<div style={{ marginTop: '1.5rem' }}>
|
|
37
|
+
<button type="button" className="btn btn-danger" onClick={vm.logout}>
|
|
38
|
+
Logout
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useState, useCallback } from 'react';
|
|
2
|
+
import { useSingleton } from 'mvc-kit/react';
|
|
3
|
+
import { useEvent } from 'mvc-kit/react';
|
|
4
|
+
import { AppEventBus } from '../events/AppEventBus';
|
|
5
|
+
|
|
6
|
+
interface ToastItem {
|
|
7
|
+
id: number;
|
|
8
|
+
message: string;
|
|
9
|
+
severity: 'success' | 'error' | 'info';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let nextId = 0;
|
|
13
|
+
|
|
14
|
+
export function Toast() {
|
|
15
|
+
const bus = useSingleton(AppEventBus);
|
|
16
|
+
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
17
|
+
|
|
18
|
+
const addToast = useCallback((item: Omit<ToastItem, 'id'>) => {
|
|
19
|
+
const id = nextId++;
|
|
20
|
+
setToasts(prev => [...prev, { ...item, id }]);
|
|
21
|
+
setTimeout(() => {
|
|
22
|
+
setToasts(prev => prev.filter(t => t.id !== id));
|
|
23
|
+
}, 3000);
|
|
24
|
+
}, []);
|
|
25
|
+
|
|
26
|
+
useEvent(bus, 'toast:show', ({ message, severity }) => {
|
|
27
|
+
addToast({ message, severity });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
if (toasts.length === 0) return null;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="toast-container">
|
|
34
|
+
{toasts.map(t => (
|
|
35
|
+
<div key={t.id} className={`toast toast-${t.severity}`}>
|
|
36
|
+
{t.message}
|
|
37
|
+
</div>
|
|
38
|
+
))}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
declare const __MVC_KIT_DEV__: boolean;
|
|
2
|
+
|
|
3
|
+
declare module 'react-dom/client' {
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
interface Root {
|
|
6
|
+
render(children: ReactNode): void;
|
|
7
|
+
unmount(): void;
|
|
8
|
+
}
|
|
9
|
+
export function createRoot(container: Element): Root;
|
|
10
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { HttpError } from 'mvc-kit';
|
|
2
|
+
import type { AuthUser, AuthResponse } from '../types/auth';
|
|
3
|
+
|
|
4
|
+
function delay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
if (signal?.aborted) {
|
|
7
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const timer = setTimeout(resolve, ms);
|
|
11
|
+
signal?.addEventListener('abort', () => {
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
reject(new DOMException('Aborted', 'AbortError'));
|
|
14
|
+
}, { once: true });
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function mockFetch<T>(data: T, ms?: number, signal?: AbortSignal): Promise<T> {
|
|
19
|
+
const jitter = 300 + Math.random() * 400;
|
|
20
|
+
await delay(ms ?? jitter, signal);
|
|
21
|
+
return data;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const USERS: AuthUser[] = [
|
|
25
|
+
{ id: '1', name: 'Alice Admin', email: 'admin@example.com', role: 'admin', createdAt: '2024-01-15T08:00:00Z' },
|
|
26
|
+
{ id: '2', name: 'Maya Manager', email: 'manager@example.com', role: 'manager', createdAt: '2024-03-22T10:30:00Z' },
|
|
27
|
+
{ id: '3', name: 'Uma User', email: 'user@example.com', role: 'member', createdAt: '2024-06-10T14:15:00Z' },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const TOKEN_MAP = new Map<string, AuthUser>();
|
|
31
|
+
let nextTokenId = 1;
|
|
32
|
+
|
|
33
|
+
function findUser(email: string): AuthUser | undefined {
|
|
34
|
+
return USERS.find(u => u.email === email);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function mockLogin(email: string, password: string, signal?: AbortSignal): Promise<AuthResponse> {
|
|
38
|
+
const user = findUser(email);
|
|
39
|
+
if (!user || password !== 'password') {
|
|
40
|
+
throw await mockFetch(new HttpError(401, 'Invalid email or password'), 200, signal);
|
|
41
|
+
}
|
|
42
|
+
const accessToken = `tok_${nextTokenId++}_${user.id}`;
|
|
43
|
+
TOKEN_MAP.set(accessToken, user);
|
|
44
|
+
return mockFetch({ user, accessToken }, undefined, signal);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function mockRegister(name: string, email: string, password: string, signal?: AbortSignal): Promise<AuthResponse> {
|
|
48
|
+
if (findUser(email)) {
|
|
49
|
+
throw await mockFetch(new HttpError(409, 'Email already registered'), 200, signal);
|
|
50
|
+
}
|
|
51
|
+
if (password.length < 6) {
|
|
52
|
+
throw await mockFetch(new HttpError(400, 'Password must be at least 6 characters'), 200, signal);
|
|
53
|
+
}
|
|
54
|
+
const user: AuthUser = {
|
|
55
|
+
id: String(USERS.length + 1),
|
|
56
|
+
name,
|
|
57
|
+
email,
|
|
58
|
+
role: 'member',
|
|
59
|
+
createdAt: new Date().toISOString(),
|
|
60
|
+
};
|
|
61
|
+
USERS.push(user);
|
|
62
|
+
const accessToken = `tok_${nextTokenId++}_${user.id}`;
|
|
63
|
+
TOKEN_MAP.set(accessToken, user);
|
|
64
|
+
return mockFetch({ user, accessToken }, undefined, signal);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function mockGetProfile(token: string, signal?: AbortSignal): Promise<AuthUser> {
|
|
68
|
+
const user = TOKEN_MAP.get(token);
|
|
69
|
+
if (!user) {
|
|
70
|
+
throw await mockFetch(new HttpError(401, 'Invalid or expired token'), 100, signal);
|
|
71
|
+
}
|
|
72
|
+
return mockFetch({ ...user }, undefined, signal);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function mockLogout(token: string, signal?: AbortSignal): Promise<void> {
|
|
76
|
+
TOKEN_MAP.delete(token);
|
|
77
|
+
await mockFetch(undefined, 200, signal);
|
|
78
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { Model } from 'mvc-kit';
|
|
2
|
+
import type { ValidationErrors } from 'mvc-kit';
|
|
3
|
+
|
|
4
|
+
export interface LoginFormState {
|
|
5
|
+
email: string;
|
|
6
|
+
password: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class LoginFormModel extends Model<LoginFormState> {
|
|
10
|
+
setEmail(email: string) { this.set({ email }); }
|
|
11
|
+
setPassword(password: string) { this.set({ password }); }
|
|
12
|
+
|
|
13
|
+
protected validate(state: LoginFormState): ValidationErrors<LoginFormState> {
|
|
14
|
+
const errors: Partial<Record<keyof LoginFormState, string>> = {};
|
|
15
|
+
if (!state.email.includes('@')) errors.email = 'Valid email required';
|
|
16
|
+
if (state.password.length < 6) errors.password = 'Must be at least 6 characters';
|
|
17
|
+
return errors;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Model } from 'mvc-kit';
|
|
2
|
+
import type { ValidationErrors } from 'mvc-kit';
|
|
3
|
+
|
|
4
|
+
export interface RegisterFormState {
|
|
5
|
+
name: string;
|
|
6
|
+
email: string;
|
|
7
|
+
password: string;
|
|
8
|
+
confirmPassword: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class RegisterFormModel extends Model<RegisterFormState> {
|
|
12
|
+
setName(name: string) { this.set({ name }); }
|
|
13
|
+
setEmail(email: string) { this.set({ email }); }
|
|
14
|
+
setPassword(password: string) { this.set({ password }); }
|
|
15
|
+
setConfirmPassword(confirmPassword: string) { this.set({ confirmPassword }); }
|
|
16
|
+
|
|
17
|
+
protected validate(state: RegisterFormState): ValidationErrors<RegisterFormState> {
|
|
18
|
+
const errors: Partial<Record<keyof RegisterFormState, string>> = {};
|
|
19
|
+
if (state.name.trim().length < 2) errors.name = 'Name is required';
|
|
20
|
+
if (!state.email.includes('@')) errors.email = 'Valid email required';
|
|
21
|
+
if (state.password.length < 6) errors.password = 'Must be at least 6 characters';
|
|
22
|
+
if (state.confirmPassword !== state.password) errors.confirmPassword = 'Passwords do not match';
|
|
23
|
+
return errors;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Service } from 'mvc-kit';
|
|
2
|
+
import type { AuthUser, AuthResponse } from '../types/auth';
|
|
3
|
+
import { mockLogin, mockRegister, mockGetProfile, mockLogout } from '../mock/api';
|
|
4
|
+
|
|
5
|
+
export class AuthService extends Service {
|
|
6
|
+
login(email: string, password: string, signal?: AbortSignal): Promise<AuthResponse> {
|
|
7
|
+
return mockLogin(email, password, signal);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
register(name: string, email: string, password: string, signal?: AbortSignal): Promise<AuthResponse> {
|
|
11
|
+
return mockRegister(name, email, password, signal);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
getProfile(token: string, signal?: AbortSignal): Promise<AuthUser> {
|
|
15
|
+
return mockGetProfile(token, signal);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
logout(token: string, signal?: AbortSignal): Promise<void> {
|
|
19
|
+
return mockLogout(token, signal);
|
|
20
|
+
}
|
|
21
|
+
}
|