@astratra/saas-kit-ui 0.1.0
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/LICENSE +21 -0
- package/README.md +46 -0
- package/package.json +36 -0
- package/src/App.jsx +38 -0
- package/src/assets/logo.svg +9 -0
- package/src/auth/AuthContext.jsx +49 -0
- package/src/components/Layout.jsx +53 -0
- package/src/components/Layout.module.css +196 -0
- package/src/components/Notice.jsx +6 -0
- package/src/components/Notice.module.css +43 -0
- package/src/index.jsx +9 -0
- package/src/lib/api.js +65 -0
- package/src/screens/Dashboard.jsx +60 -0
- package/src/screens/Login.jsx +57 -0
- package/src/screens/Login.module.css +163 -0
- package/src/screens/Screen.module.css +248 -0
- package/src/screens/Settings.jsx +78 -0
- package/src/screens/Users.jsx +100 -0
- package/src/styles/global.css +98 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Astratra
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# @astratra/saas-kit-ui
|
|
2
|
+
|
|
3
|
+
Interface React reutilisable pour demarrer rapidement un dashboard SaaS
|
|
4
|
+
Astratra. Le package expose une application dashboard complete et quelques
|
|
5
|
+
primitives (`AuthProvider`, `useAuth`, `apiFetch`) pour composer une interface
|
|
6
|
+
plus specifique.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @astratra/saas-kit-ui react react-dom
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Dans le repo Astratra, `examples/dashboard-ui` montre son integration avec un
|
|
15
|
+
backend `@astratra/saas-kit`.
|
|
16
|
+
|
|
17
|
+
## Utilisation
|
|
18
|
+
|
|
19
|
+
```jsx
|
|
20
|
+
import React from 'react';
|
|
21
|
+
import { createRoot } from 'react-dom/client';
|
|
22
|
+
import { AstratraDashboardApp } from '@astratra/saas-kit-ui';
|
|
23
|
+
|
|
24
|
+
createRoot(document.getElementById('root')).render(
|
|
25
|
+
<React.StrictMode>
|
|
26
|
+
<AstratraDashboardApp />
|
|
27
|
+
</React.StrictMode>
|
|
28
|
+
);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Par defaut, l'interface appelle `http://localhost:4000`. Avec Vite, definis
|
|
32
|
+
`VITE_API_URL` pour pointer vers ton backend :
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
VITE_API_URL=http://localhost:4000 npm run dev
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Surface exportee
|
|
39
|
+
|
|
40
|
+
- `AstratraDashboardApp` : dashboard complet login/users/settings/overview.
|
|
41
|
+
- `AuthProvider` et `useAuth` : session bearer token en memoire.
|
|
42
|
+
- `apiFetch` et `ApiError` : client minimal compatible avec les reponses
|
|
43
|
+
`apiResponse` d'`@astratra/core`.
|
|
44
|
+
|
|
45
|
+
Le package ne contient pas de serveur Express et ne fixe aucun domaine metier.
|
|
46
|
+
Le backend reste fourni par `@astratra/saas-kit` ou par ton application.
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@astratra/saas-kit-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reusable React dashboard shell for Astratra SaaS applications.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.jsx",
|
|
7
|
+
"module": "src/index.jsx",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.jsx",
|
|
10
|
+
"./api": "./src/lib/api.js",
|
|
11
|
+
"./styles.css": "./src/styles/global.css"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test __tests__/*.test.js"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"astratra",
|
|
21
|
+
"react",
|
|
22
|
+
"dashboard",
|
|
23
|
+
"saas"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"react": ">=18",
|
|
31
|
+
"react-dom": ">=18"
|
|
32
|
+
},
|
|
33
|
+
"sideEffects": [
|
|
34
|
+
"**/*.css"
|
|
35
|
+
]
|
|
36
|
+
}
|
package/src/App.jsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { AuthProvider, useAuth } from './auth/AuthContext.jsx';
|
|
3
|
+
import Layout from './components/Layout.jsx';
|
|
4
|
+
import Dashboard from './screens/Dashboard.jsx';
|
|
5
|
+
import Login from './screens/Login.jsx';
|
|
6
|
+
import Settings from './screens/Settings.jsx';
|
|
7
|
+
import Users from './screens/Users.jsx';
|
|
8
|
+
import './styles/global.css';
|
|
9
|
+
|
|
10
|
+
const VIEWS = {
|
|
11
|
+
dashboard: Dashboard,
|
|
12
|
+
users: Users,
|
|
13
|
+
settings: Settings
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function GuardedApp() {
|
|
17
|
+
const [view, setView] = useState('dashboard');
|
|
18
|
+
const auth = useAuth();
|
|
19
|
+
|
|
20
|
+
if (!auth.isAuthenticated) {
|
|
21
|
+
return <Login onLoggedIn={() => setView('dashboard')} />;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const Screen = VIEWS[view] || Dashboard;
|
|
25
|
+
return (
|
|
26
|
+
<Layout activeView={view} onNavigate={setView}>
|
|
27
|
+
<Screen />
|
|
28
|
+
</Layout>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export default function AstratraDashboardApp() {
|
|
33
|
+
return (
|
|
34
|
+
<AuthProvider>
|
|
35
|
+
<GuardedApp />
|
|
36
|
+
</AuthProvider>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg width="256" height="256" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Astratra">
|
|
2
|
+
<rect width="48" height="48" fill="#17201b" />
|
|
3
|
+
<path
|
|
4
|
+
fill-rule="evenodd"
|
|
5
|
+
clip-rule="evenodd"
|
|
6
|
+
d="M24 8 L6 40 L42 40 Z M24 19 L16 31 L32 31 Z"
|
|
7
|
+
fill="#d9ff66"
|
|
8
|
+
/>
|
|
9
|
+
</svg>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
|
2
|
+
import { apiFetch } from '../lib/api.js';
|
|
3
|
+
|
|
4
|
+
const AuthContext = createContext(null);
|
|
5
|
+
|
|
6
|
+
export function AuthProvider({ children }) {
|
|
7
|
+
const [token, setToken] = useState(null);
|
|
8
|
+
const [user, setUser] = useState(null);
|
|
9
|
+
|
|
10
|
+
const logout = useCallback(() => {
|
|
11
|
+
setToken(null);
|
|
12
|
+
setUser(null);
|
|
13
|
+
}, []);
|
|
14
|
+
|
|
15
|
+
const login = useCallback(async ({ email, password }) => {
|
|
16
|
+
const data = await apiFetch('/auth/login', {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
body: { email, password }
|
|
19
|
+
});
|
|
20
|
+
setToken(data.token);
|
|
21
|
+
setUser(data.user);
|
|
22
|
+
return data.user;
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
const request = useCallback((path, options = {}) => apiFetch(path, {
|
|
26
|
+
...options,
|
|
27
|
+
token,
|
|
28
|
+
onUnauthorized: logout
|
|
29
|
+
}), [logout, token]);
|
|
30
|
+
|
|
31
|
+
const value = useMemo(() => ({
|
|
32
|
+
isAuthenticated: Boolean(token),
|
|
33
|
+
token,
|
|
34
|
+
user,
|
|
35
|
+
login,
|
|
36
|
+
logout,
|
|
37
|
+
request
|
|
38
|
+
}), [login, logout, request, token, user]);
|
|
39
|
+
|
|
40
|
+
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function useAuth() {
|
|
44
|
+
const value = useContext(AuthContext);
|
|
45
|
+
if (!value) {
|
|
46
|
+
throw new Error('useAuth must be used inside AuthProvider.');
|
|
47
|
+
}
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { useAuth } from '../auth/AuthContext.jsx';
|
|
2
|
+
import logoUrl from '../assets/logo.svg';
|
|
3
|
+
import styles from './Layout.module.css';
|
|
4
|
+
|
|
5
|
+
const NAV_ITEMS = [
|
|
6
|
+
{ id: 'dashboard', index: '01', label: 'Dashboard' },
|
|
7
|
+
{ id: 'users', index: '02', label: 'Users' },
|
|
8
|
+
{ id: 'settings', index: '03', label: 'Settings' }
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export default function Layout({ activeView, children, onNavigate }) {
|
|
12
|
+
const { logout, user } = useAuth();
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div className={styles.shell}>
|
|
16
|
+
<aside className={styles.sidebar}>
|
|
17
|
+
<div className={styles.brand}>
|
|
18
|
+
<img src={logoUrl} alt="Astratra" className={styles.brandMark} />
|
|
19
|
+
<span>
|
|
20
|
+
<strong>Astratra</strong>
|
|
21
|
+
<small>saas_starter@v0.1.0</small>
|
|
22
|
+
</span>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<nav className={styles.nav} aria-label="Primary">
|
|
26
|
+
{NAV_ITEMS.map((item) => (
|
|
27
|
+
<button
|
|
28
|
+
key={item.id}
|
|
29
|
+
type="button"
|
|
30
|
+
className={item.id === activeView ? styles.activeNavItem : styles.navItem}
|
|
31
|
+
onClick={() => onNavigate(item.id)}
|
|
32
|
+
>
|
|
33
|
+
<span className={styles.navIndex}>{item.index}</span>
|
|
34
|
+
{item.label}
|
|
35
|
+
</button>
|
|
36
|
+
))}
|
|
37
|
+
</nav>
|
|
38
|
+
|
|
39
|
+
<div className={styles.status}>
|
|
40
|
+
<span className={styles.statusDot} aria-hidden="true" />
|
|
41
|
+
session active
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<div className={styles.account}>
|
|
45
|
+
<span>{user?.email}</span>
|
|
46
|
+
<small>role: {user?.role}</small>
|
|
47
|
+
<button type="button" onClick={logout}>[ logout ]</button>
|
|
48
|
+
</div>
|
|
49
|
+
</aside>
|
|
50
|
+
<main className={styles.content}>{children}</main>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
.shell {
|
|
2
|
+
min-height: 100vh;
|
|
3
|
+
display: grid;
|
|
4
|
+
grid-template-columns: 260px minmax(0, 1fr);
|
|
5
|
+
background: transparent;
|
|
6
|
+
color: var(--cream);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.sidebar {
|
|
10
|
+
display: flex;
|
|
11
|
+
flex-direction: column;
|
|
12
|
+
gap: 24px;
|
|
13
|
+
padding: 24px 20px;
|
|
14
|
+
background: var(--panel);
|
|
15
|
+
border-right: 1px solid var(--line);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.brand {
|
|
19
|
+
display: flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
gap: 12px;
|
|
22
|
+
padding-bottom: 20px;
|
|
23
|
+
border-bottom: 1px solid var(--line);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.brandMark {
|
|
27
|
+
width: 38px;
|
|
28
|
+
height: 38px;
|
|
29
|
+
flex: none;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.brand strong,
|
|
33
|
+
.brand small,
|
|
34
|
+
.account span,
|
|
35
|
+
.account small {
|
|
36
|
+
display: block;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.brand strong {
|
|
40
|
+
font-size: 0.95rem;
|
|
41
|
+
letter-spacing: 0.02em;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.brand small,
|
|
45
|
+
.account small {
|
|
46
|
+
color: var(--muted);
|
|
47
|
+
margin-top: 3px;
|
|
48
|
+
font-size: 0.72rem;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.nav {
|
|
52
|
+
display: grid;
|
|
53
|
+
gap: 4px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.navItem,
|
|
57
|
+
.activeNavItem {
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
gap: 10px;
|
|
61
|
+
min-height: 40px;
|
|
62
|
+
border: 0;
|
|
63
|
+
border-left: 2px solid transparent;
|
|
64
|
+
padding: 0 12px;
|
|
65
|
+
text-align: left;
|
|
66
|
+
font: inherit;
|
|
67
|
+
font-size: 0.85rem;
|
|
68
|
+
cursor: pointer;
|
|
69
|
+
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.navIndex {
|
|
73
|
+
color: var(--muted);
|
|
74
|
+
font-size: 0.75rem;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.navItem {
|
|
78
|
+
background: transparent;
|
|
79
|
+
color: var(--muted);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.navItem:hover {
|
|
83
|
+
color: var(--cream);
|
|
84
|
+
background: var(--panel-raised);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.activeNavItem {
|
|
88
|
+
background: var(--panel-raised);
|
|
89
|
+
color: var(--lime);
|
|
90
|
+
border-left-color: var(--lime);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.activeNavItem .navIndex {
|
|
94
|
+
color: var(--lime-dim);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.status {
|
|
98
|
+
display: flex;
|
|
99
|
+
align-items: center;
|
|
100
|
+
gap: 8px;
|
|
101
|
+
font-size: 0.72rem;
|
|
102
|
+
color: var(--muted);
|
|
103
|
+
text-transform: uppercase;
|
|
104
|
+
letter-spacing: 0.05em;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.statusDot {
|
|
108
|
+
width: 7px;
|
|
109
|
+
height: 7px;
|
|
110
|
+
border-radius: 50%;
|
|
111
|
+
background: var(--lime);
|
|
112
|
+
box-shadow: 0 0 0 0 rgba(217, 255, 102, 0.6);
|
|
113
|
+
animation: pulse 2.2s ease-out infinite;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
@keyframes pulse {
|
|
117
|
+
0% {
|
|
118
|
+
box-shadow: 0 0 0 0 rgba(217, 255, 102, 0.5);
|
|
119
|
+
}
|
|
120
|
+
70% {
|
|
121
|
+
box-shadow: 0 0 0 6px rgba(217, 255, 102, 0);
|
|
122
|
+
}
|
|
123
|
+
100% {
|
|
124
|
+
box-shadow: 0 0 0 0 rgba(217, 255, 102, 0);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.account {
|
|
129
|
+
margin-top: auto;
|
|
130
|
+
display: grid;
|
|
131
|
+
gap: 10px;
|
|
132
|
+
padding-top: 16px;
|
|
133
|
+
border-top: 1px solid var(--line);
|
|
134
|
+
font-size: 0.82rem;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.account span {
|
|
138
|
+
overflow: hidden;
|
|
139
|
+
text-overflow: ellipsis;
|
|
140
|
+
white-space: nowrap;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.account button {
|
|
144
|
+
text-align: left;
|
|
145
|
+
border: 1px solid var(--line-bright);
|
|
146
|
+
background: transparent;
|
|
147
|
+
color: var(--muted);
|
|
148
|
+
padding: 8px 10px;
|
|
149
|
+
font: inherit;
|
|
150
|
+
font-size: 0.8rem;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.account button:hover {
|
|
154
|
+
color: var(--error);
|
|
155
|
+
border-color: var(--error);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.content {
|
|
159
|
+
min-width: 0;
|
|
160
|
+
padding: 40px;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@media (max-width: 760px) {
|
|
164
|
+
.shell {
|
|
165
|
+
grid-template-columns: 1fr;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.sidebar {
|
|
169
|
+
position: static;
|
|
170
|
+
gap: 16px;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.nav {
|
|
174
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
175
|
+
gap: 8px;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.navItem,
|
|
179
|
+
.activeNavItem {
|
|
180
|
+
border-left: 0;
|
|
181
|
+
border-bottom: 2px solid transparent;
|
|
182
|
+
justify-content: center;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.activeNavItem {
|
|
186
|
+
border-bottom-color: var(--lime);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.status {
|
|
190
|
+
display: none;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.content {
|
|
194
|
+
padding: 20px;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
.notice {
|
|
2
|
+
position: relative;
|
|
3
|
+
padding: 12px 14px 12px 34px;
|
|
4
|
+
border: 1px solid var(--line-bright);
|
|
5
|
+
background: var(--panel);
|
|
6
|
+
color: var(--cream);
|
|
7
|
+
font-size: 0.82rem;
|
|
8
|
+
line-height: 1.5;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.notice::before {
|
|
12
|
+
content: "!";
|
|
13
|
+
position: absolute;
|
|
14
|
+
top: 12px;
|
|
15
|
+
left: 14px;
|
|
16
|
+
color: var(--muted);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.error {
|
|
20
|
+
border-color: var(--error);
|
|
21
|
+
background: var(--error-bg);
|
|
22
|
+
color: var(--cream);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.error::before {
|
|
26
|
+
content: "x";
|
|
27
|
+
color: var(--error);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.success {
|
|
31
|
+
border-color: var(--success);
|
|
32
|
+
background: var(--success-bg);
|
|
33
|
+
color: var(--cream);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.success::before {
|
|
37
|
+
content: ">";
|
|
38
|
+
color: var(--success);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.neutral {
|
|
42
|
+
border-color: var(--line-bright);
|
|
43
|
+
}
|
package/src/index.jsx
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { default, default as AstratraDashboardApp } from './App.jsx';
|
|
2
|
+
export { AuthProvider, useAuth } from './auth/AuthContext.jsx';
|
|
3
|
+
export { ApiError, apiFetch } from './lib/api.js';
|
|
4
|
+
export { default as Layout } from './components/Layout.jsx';
|
|
5
|
+
export { default as Notice } from './components/Notice.jsx';
|
|
6
|
+
export { default as DashboardScreen } from './screens/Dashboard.jsx';
|
|
7
|
+
export { default as LoginScreen } from './screens/Login.jsx';
|
|
8
|
+
export { default as SettingsScreen } from './screens/Settings.jsx';
|
|
9
|
+
export { default as UsersScreen } from './screens/Users.jsx';
|
package/src/lib/api.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const DEFAULT_API_URL = 'http://localhost:4000';
|
|
2
|
+
|
|
3
|
+
export class ApiError extends Error {
|
|
4
|
+
constructor(message, { status, data, error } = {}) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'ApiError';
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.data = data;
|
|
9
|
+
this.error = error;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function joinUrl(apiUrl, path) {
|
|
14
|
+
const base = String(apiUrl || DEFAULT_API_URL).replace(/\/+$/, '');
|
|
15
|
+
const suffix = String(path || '').startsWith('/') ? path : `/${path}`;
|
|
16
|
+
return `${base}${suffix}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function readPayload(response) {
|
|
20
|
+
const text = await response.text();
|
|
21
|
+
if (!text) return null;
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(text);
|
|
25
|
+
} catch {
|
|
26
|
+
throw new ApiError('The API returned an invalid JSON response.', {
|
|
27
|
+
status: response.status,
|
|
28
|
+
data: text
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function apiFetch(path, {
|
|
34
|
+
apiUrl = import.meta.env?.VITE_API_URL || DEFAULT_API_URL,
|
|
35
|
+
token,
|
|
36
|
+
method = 'GET',
|
|
37
|
+
body,
|
|
38
|
+
fetcher = fetch,
|
|
39
|
+
onUnauthorized
|
|
40
|
+
} = {}) {
|
|
41
|
+
const headers = {};
|
|
42
|
+
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
43
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
44
|
+
|
|
45
|
+
const response = await fetcher(joinUrl(apiUrl, path), {
|
|
46
|
+
method,
|
|
47
|
+
headers,
|
|
48
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
49
|
+
});
|
|
50
|
+
const payload = await readPayload(response);
|
|
51
|
+
|
|
52
|
+
if (response.status === 401 && typeof onUnauthorized === 'function') {
|
|
53
|
+
onUnauthorized();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!response.ok || payload?.success === false) {
|
|
57
|
+
throw new ApiError(payload?.message || payload?.error || `Request failed with status ${response.status}.`, {
|
|
58
|
+
status: response.status,
|
|
59
|
+
data: payload?.data,
|
|
60
|
+
error: payload?.error
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return payload?.data;
|
|
65
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useAuth } from '../auth/AuthContext.jsx';
|
|
3
|
+
import Notice from '../components/Notice.jsx';
|
|
4
|
+
import styles from './Screen.module.css';
|
|
5
|
+
|
|
6
|
+
export default function Dashboard() {
|
|
7
|
+
const { request } = useAuth();
|
|
8
|
+
const [summary, setSummary] = useState(null);
|
|
9
|
+
const [error, setError] = useState('');
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
let active = true;
|
|
13
|
+
request('/dashboard/summary')
|
|
14
|
+
.then((data) => {
|
|
15
|
+
if (active) setSummary(data);
|
|
16
|
+
})
|
|
17
|
+
.catch((apiError) => {
|
|
18
|
+
if (active) setError(apiError.message);
|
|
19
|
+
});
|
|
20
|
+
return () => {
|
|
21
|
+
active = false;
|
|
22
|
+
};
|
|
23
|
+
}, [request]);
|
|
24
|
+
|
|
25
|
+
const roles = Object.entries(summary?.roleBreakdown || {});
|
|
26
|
+
const maxRoleCount = Math.max(1, ...roles.map(([, count]) => count));
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<section className={styles.screen}>
|
|
30
|
+
<header className={styles.header}>
|
|
31
|
+
<p>Overview</p>
|
|
32
|
+
<h1>Dashboard</h1>
|
|
33
|
+
</header>
|
|
34
|
+
<Notice tone="error">{error}</Notice>
|
|
35
|
+
{!summary && !error ? <p className={styles.muted}>Loading dashboard...</p> : null}
|
|
36
|
+
{summary ? (
|
|
37
|
+
<div className={styles.grid}>
|
|
38
|
+
<article className={styles.statCard}>
|
|
39
|
+
<span>Total users</span>
|
|
40
|
+
<strong>{summary.userCount}</strong>
|
|
41
|
+
</article>
|
|
42
|
+
<article className={styles.wideCard}>
|
|
43
|
+
<span>Role breakdown</span>
|
|
44
|
+
<div className={styles.bars}>
|
|
45
|
+
{roles.map(([role, count]) => (
|
|
46
|
+
<div key={role} className={styles.barRow}>
|
|
47
|
+
<span>{role}</span>
|
|
48
|
+
<div className={styles.track}>
|
|
49
|
+
<div style={{ width: `${(count / maxRoleCount) * 100}%` }} />
|
|
50
|
+
</div>
|
|
51
|
+
<strong>{count}</strong>
|
|
52
|
+
</div>
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
</article>
|
|
56
|
+
</div>
|
|
57
|
+
) : null}
|
|
58
|
+
</section>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { useAuth } from '../auth/AuthContext.jsx';
|
|
3
|
+
import Notice from '../components/Notice.jsx';
|
|
4
|
+
import styles from './Login.module.css';
|
|
5
|
+
|
|
6
|
+
export default function Login({ onLoggedIn }) {
|
|
7
|
+
const { login } = useAuth();
|
|
8
|
+
const [email, setEmail] = useState('owner@example.test');
|
|
9
|
+
const [password, setPassword] = useState('password');
|
|
10
|
+
const [error, setError] = useState('');
|
|
11
|
+
const [loading, setLoading] = useState(false);
|
|
12
|
+
|
|
13
|
+
async function handleSubmit(event) {
|
|
14
|
+
event.preventDefault();
|
|
15
|
+
setError('');
|
|
16
|
+
setLoading(true);
|
|
17
|
+
try {
|
|
18
|
+
await login({ email, password });
|
|
19
|
+
onLoggedIn();
|
|
20
|
+
} catch (apiError) {
|
|
21
|
+
setError(apiError.message);
|
|
22
|
+
} finally {
|
|
23
|
+
setLoading(false);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<main className={styles.page}>
|
|
29
|
+
<section className={styles.panel} aria-labelledby="login-title">
|
|
30
|
+
<div className={styles.frame} aria-hidden="true" />
|
|
31
|
+
<div className={styles.identity}>
|
|
32
|
+
<span className={styles.tag}>astratra/saas-kit</span>
|
|
33
|
+
<span className={styles.tagMuted}>v0.1.0</span>
|
|
34
|
+
</div>
|
|
35
|
+
<h1 id="login-title">
|
|
36
|
+
<span className={styles.prompt}>></span> sign_in
|
|
37
|
+
<span className={styles.cursor} aria-hidden="true" />
|
|
38
|
+
</h1>
|
|
39
|
+
<p className={styles.copy}>
|
|
40
|
+
// authenticate with the seeded owner account to inspect the generic SaaS API screens
|
|
41
|
+
</p>
|
|
42
|
+
<Notice tone="error">{error}</Notice>
|
|
43
|
+
<form className={styles.form} onSubmit={handleSubmit}>
|
|
44
|
+
<label>
|
|
45
|
+
email
|
|
46
|
+
<input value={email} onChange={(event) => setEmail(event.target.value)} type="email" required />
|
|
47
|
+
</label>
|
|
48
|
+
<label>
|
|
49
|
+
password
|
|
50
|
+
<input value={password} onChange={(event) => setPassword(event.target.value)} type="password" required />
|
|
51
|
+
</label>
|
|
52
|
+
<button type="submit" disabled={loading}>{loading ? 'authenticating...' : '[ sign in ]'}</button>
|
|
53
|
+
</form>
|
|
54
|
+
</section>
|
|
55
|
+
</main>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
.page {
|
|
2
|
+
min-height: 100vh;
|
|
3
|
+
display: grid;
|
|
4
|
+
place-items: center;
|
|
5
|
+
padding: 24px;
|
|
6
|
+
background:
|
|
7
|
+
radial-gradient(circle at 20% -10%, rgba(217, 255, 102, 0.08), transparent 40%),
|
|
8
|
+
var(--bg);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.panel {
|
|
12
|
+
position: relative;
|
|
13
|
+
width: min(440px, 100%);
|
|
14
|
+
display: grid;
|
|
15
|
+
gap: 18px;
|
|
16
|
+
padding: 40px;
|
|
17
|
+
background: var(--panel);
|
|
18
|
+
color: var(--cream);
|
|
19
|
+
border: 1px solid var(--line);
|
|
20
|
+
animation: rise 420ms ease both;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@keyframes rise {
|
|
24
|
+
from {
|
|
25
|
+
opacity: 0;
|
|
26
|
+
transform: translateY(10px);
|
|
27
|
+
}
|
|
28
|
+
to {
|
|
29
|
+
opacity: 1;
|
|
30
|
+
transform: translateY(0);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.frame {
|
|
35
|
+
position: absolute;
|
|
36
|
+
inset: -1px;
|
|
37
|
+
pointer-events: none;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.frame::before,
|
|
41
|
+
.frame::after {
|
|
42
|
+
content: "";
|
|
43
|
+
position: absolute;
|
|
44
|
+
width: 18px;
|
|
45
|
+
height: 18px;
|
|
46
|
+
border: 2px solid var(--lime);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.frame::before {
|
|
50
|
+
top: -2px;
|
|
51
|
+
left: -2px;
|
|
52
|
+
border-right: 0;
|
|
53
|
+
border-bottom: 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.frame::after {
|
|
57
|
+
bottom: -2px;
|
|
58
|
+
right: -2px;
|
|
59
|
+
border-left: 0;
|
|
60
|
+
border-top: 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.identity {
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: baseline;
|
|
66
|
+
gap: 8px;
|
|
67
|
+
font-size: 0.75rem;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.tag {
|
|
71
|
+
color: var(--lime);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.tagMuted {
|
|
75
|
+
color: var(--muted);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
h1 {
|
|
79
|
+
margin: 0;
|
|
80
|
+
display: flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
font-size: 2rem;
|
|
83
|
+
line-height: 1;
|
|
84
|
+
font-weight: 500;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.prompt {
|
|
88
|
+
color: var(--lime);
|
|
89
|
+
margin-right: 10px;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.cursor {
|
|
93
|
+
display: inline-block;
|
|
94
|
+
width: 0.55em;
|
|
95
|
+
height: 1.1em;
|
|
96
|
+
margin-left: 8px;
|
|
97
|
+
background: var(--lime);
|
|
98
|
+
animation: blink 1s steps(1) infinite;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@keyframes blink {
|
|
102
|
+
50% {
|
|
103
|
+
opacity: 0;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.copy {
|
|
108
|
+
margin: 0;
|
|
109
|
+
color: var(--muted);
|
|
110
|
+
font-size: 0.85rem;
|
|
111
|
+
line-height: 1.6;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.form {
|
|
115
|
+
display: grid;
|
|
116
|
+
gap: 14px;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.form label {
|
|
120
|
+
display: grid;
|
|
121
|
+
gap: 6px;
|
|
122
|
+
font-size: 0.78rem;
|
|
123
|
+
color: var(--muted);
|
|
124
|
+
text-transform: lowercase;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.form input {
|
|
128
|
+
min-height: 42px;
|
|
129
|
+
border: 1px solid var(--line-bright);
|
|
130
|
+
background: var(--bg);
|
|
131
|
+
color: var(--cream);
|
|
132
|
+
padding: 0 12px;
|
|
133
|
+
font: inherit;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
.form input:focus {
|
|
137
|
+
border-color: var(--lime);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.form button {
|
|
141
|
+
min-height: 44px;
|
|
142
|
+
border: 1px solid var(--lime);
|
|
143
|
+
background: var(--lime);
|
|
144
|
+
color: var(--bg);
|
|
145
|
+
font: inherit;
|
|
146
|
+
font-weight: 700;
|
|
147
|
+
margin-top: 6px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.form button:disabled {
|
|
151
|
+
cursor: wait;
|
|
152
|
+
opacity: 0.6;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@media (max-width: 520px) {
|
|
156
|
+
.panel {
|
|
157
|
+
padding: 28px;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
h1 {
|
|
161
|
+
font-size: 1.6rem;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
.screen {
|
|
2
|
+
display: grid;
|
|
3
|
+
gap: 24px;
|
|
4
|
+
animation: fadeUp 320ms ease both;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
@keyframes fadeUp {
|
|
8
|
+
from {
|
|
9
|
+
opacity: 0;
|
|
10
|
+
transform: translateY(6px);
|
|
11
|
+
}
|
|
12
|
+
to {
|
|
13
|
+
opacity: 1;
|
|
14
|
+
transform: translateY(0);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.header {
|
|
19
|
+
display: grid;
|
|
20
|
+
gap: 6px;
|
|
21
|
+
padding-bottom: 20px;
|
|
22
|
+
border-bottom: 1px solid var(--line);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.header p {
|
|
26
|
+
margin: 0;
|
|
27
|
+
color: var(--lime-dim);
|
|
28
|
+
font-size: 0.78rem;
|
|
29
|
+
letter-spacing: 0.03em;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.header p::before {
|
|
33
|
+
content: "// ";
|
|
34
|
+
color: var(--muted);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.header h1 {
|
|
38
|
+
margin: 0;
|
|
39
|
+
font-size: 2.4rem;
|
|
40
|
+
line-height: 1;
|
|
41
|
+
font-weight: 500;
|
|
42
|
+
text-transform: lowercase;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.muted {
|
|
46
|
+
color: var(--muted);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.grid {
|
|
50
|
+
display: grid;
|
|
51
|
+
grid-template-columns: minmax(200px, 280px) minmax(0, 1fr);
|
|
52
|
+
gap: 16px;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.statCard,
|
|
56
|
+
.wideCard {
|
|
57
|
+
position: relative;
|
|
58
|
+
background: var(--panel);
|
|
59
|
+
border: 1px solid var(--line);
|
|
60
|
+
padding: 22px;
|
|
61
|
+
animation: fadeUp 380ms ease both;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.statCard::before,
|
|
65
|
+
.statCard::after,
|
|
66
|
+
.wideCard::before,
|
|
67
|
+
.wideCard::after {
|
|
68
|
+
content: "";
|
|
69
|
+
position: absolute;
|
|
70
|
+
width: 10px;
|
|
71
|
+
height: 10px;
|
|
72
|
+
border: 1px solid var(--line-bright);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.statCard::before,
|
|
76
|
+
.wideCard::before {
|
|
77
|
+
top: -1px;
|
|
78
|
+
left: -1px;
|
|
79
|
+
border-right: 0;
|
|
80
|
+
border-bottom: 0;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.statCard::after,
|
|
84
|
+
.wideCard::after {
|
|
85
|
+
bottom: -1px;
|
|
86
|
+
right: -1px;
|
|
87
|
+
border-left: 0;
|
|
88
|
+
border-top: 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.wideCard {
|
|
92
|
+
animation-delay: 60ms;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.statCard span,
|
|
96
|
+
.wideCard > span {
|
|
97
|
+
color: var(--muted);
|
|
98
|
+
font-size: 0.78rem;
|
|
99
|
+
text-transform: lowercase;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.statCard strong {
|
|
103
|
+
display: block;
|
|
104
|
+
margin-top: 14px;
|
|
105
|
+
font-size: 4rem;
|
|
106
|
+
line-height: 1;
|
|
107
|
+
font-weight: 500;
|
|
108
|
+
color: var(--lime);
|
|
109
|
+
font-variant-numeric: tabular-nums;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.bars {
|
|
113
|
+
display: grid;
|
|
114
|
+
gap: 14px;
|
|
115
|
+
margin-top: 20px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.barRow {
|
|
119
|
+
display: grid;
|
|
120
|
+
grid-template-columns: 90px minmax(80px, 1fr) 30px;
|
|
121
|
+
align-items: center;
|
|
122
|
+
gap: 12px;
|
|
123
|
+
font-size: 0.85rem;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.track {
|
|
127
|
+
height: 6px;
|
|
128
|
+
background: var(--line);
|
|
129
|
+
overflow: hidden;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.track div {
|
|
133
|
+
height: 100%;
|
|
134
|
+
background: var(--lime);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
.inlineForm {
|
|
138
|
+
display: grid;
|
|
139
|
+
grid-template-columns: minmax(180px, 1fr) 140px 160px auto;
|
|
140
|
+
gap: 10px;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.inlineForm input,
|
|
144
|
+
.inlineForm select,
|
|
145
|
+
.settingRow input {
|
|
146
|
+
min-height: 42px;
|
|
147
|
+
border: 1px solid var(--line-bright);
|
|
148
|
+
padding: 0 12px;
|
|
149
|
+
background: var(--panel);
|
|
150
|
+
color: var(--cream);
|
|
151
|
+
font: inherit;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.inlineForm input:focus,
|
|
155
|
+
.inlineForm select:focus,
|
|
156
|
+
.settingRow input:focus {
|
|
157
|
+
border-color: var(--lime);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.inlineForm button,
|
|
161
|
+
.settingRow button {
|
|
162
|
+
min-height: 42px;
|
|
163
|
+
border: 1px solid var(--lime);
|
|
164
|
+
padding: 0 16px;
|
|
165
|
+
background: var(--lime);
|
|
166
|
+
color: var(--bg);
|
|
167
|
+
font: inherit;
|
|
168
|
+
font-weight: 700;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.inlineForm button:hover,
|
|
172
|
+
.settingRow button:hover {
|
|
173
|
+
background: var(--cream);
|
|
174
|
+
border-color: var(--cream);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.inlineForm button:active,
|
|
178
|
+
.settingRow button:active {
|
|
179
|
+
transform: translateY(1px);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.tableWrap {
|
|
183
|
+
overflow-x: auto;
|
|
184
|
+
border: 1px solid var(--line);
|
|
185
|
+
background: var(--panel);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
table {
|
|
189
|
+
width: 100%;
|
|
190
|
+
border-collapse: collapse;
|
|
191
|
+
font-size: 0.88rem;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
th,
|
|
195
|
+
td {
|
|
196
|
+
padding: 14px 16px;
|
|
197
|
+
text-align: left;
|
|
198
|
+
border-bottom: 1px solid var(--line);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
tbody tr {
|
|
202
|
+
transition: background-color 120ms ease;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
tbody tr:hover {
|
|
206
|
+
background: rgba(217, 255, 102, 0.05);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
th {
|
|
210
|
+
color: var(--muted);
|
|
211
|
+
font-size: 0.72rem;
|
|
212
|
+
text-transform: lowercase;
|
|
213
|
+
font-weight: 400;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.settingsList {
|
|
217
|
+
display: grid;
|
|
218
|
+
gap: 10px;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.settingRow {
|
|
222
|
+
display: grid;
|
|
223
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
224
|
+
gap: 10px;
|
|
225
|
+
align-items: end;
|
|
226
|
+
padding: 16px;
|
|
227
|
+
background: var(--panel);
|
|
228
|
+
border: 1px solid var(--line);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.settingRow label {
|
|
232
|
+
display: grid;
|
|
233
|
+
gap: 6px;
|
|
234
|
+
font-size: 0.82rem;
|
|
235
|
+
color: var(--muted);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
@media (max-width: 860px) {
|
|
239
|
+
.grid,
|
|
240
|
+
.inlineForm,
|
|
241
|
+
.settingRow {
|
|
242
|
+
grid-template-columns: 1fr;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.header h1 {
|
|
246
|
+
font-size: 2rem;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useAuth } from '../auth/AuthContext.jsx';
|
|
3
|
+
import Notice from '../components/Notice.jsx';
|
|
4
|
+
import styles from './Screen.module.css';
|
|
5
|
+
|
|
6
|
+
export default function Settings() {
|
|
7
|
+
const { request } = useAuth();
|
|
8
|
+
const [settings, setSettings] = useState({});
|
|
9
|
+
const [drafts, setDrafts] = useState({});
|
|
10
|
+
const [error, setError] = useState('');
|
|
11
|
+
const [success, setSuccess] = useState('');
|
|
12
|
+
|
|
13
|
+
async function loadSettings() {
|
|
14
|
+
setError('');
|
|
15
|
+
try {
|
|
16
|
+
const data = await request('/settings');
|
|
17
|
+
setSettings(data || {});
|
|
18
|
+
setDrafts(Object.fromEntries(Object.entries(data || {}).map(([key, value]) => [key, JSON.stringify(value)])));
|
|
19
|
+
} catch (apiError) {
|
|
20
|
+
setError(apiError.status === 403 ? 'Not authorized to manage settings.' : apiError.message);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
loadSettings();
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
async function saveSetting(key) {
|
|
29
|
+
setError('');
|
|
30
|
+
setSuccess('');
|
|
31
|
+
let value = drafts[key];
|
|
32
|
+
try {
|
|
33
|
+
value = JSON.parse(drafts[key]);
|
|
34
|
+
} catch {
|
|
35
|
+
value = drafts[key];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const updated = await request(`/settings/${encodeURIComponent(key)}`, {
|
|
40
|
+
method: 'PATCH',
|
|
41
|
+
body: { value }
|
|
42
|
+
});
|
|
43
|
+
setSettings({ ...settings, [updated.key]: updated.value });
|
|
44
|
+
setDrafts({ ...drafts, [updated.key]: JSON.stringify(updated.value) });
|
|
45
|
+
setSuccess(`${updated.key} saved.`);
|
|
46
|
+
} catch (apiError) {
|
|
47
|
+
setError(apiError.status === 403 ? 'Not authorized to update settings.' : apiError.message);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const rows = Object.entries(settings);
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<section className={styles.screen}>
|
|
55
|
+
<header className={styles.header}>
|
|
56
|
+
<p>Configuration</p>
|
|
57
|
+
<h1>Settings</h1>
|
|
58
|
+
</header>
|
|
59
|
+
<Notice tone="error">{error}</Notice>
|
|
60
|
+
<Notice tone="success">{success}</Notice>
|
|
61
|
+
{rows.length === 0 && !error ? <p className={styles.muted}>No settings yet.</p> : null}
|
|
62
|
+
<div className={styles.settingsList}>
|
|
63
|
+
{rows.map(([key]) => (
|
|
64
|
+
<div className={styles.settingRow} key={key}>
|
|
65
|
+
<label>
|
|
66
|
+
<span>{key}</span>
|
|
67
|
+
<input
|
|
68
|
+
value={drafts[key] ?? ''}
|
|
69
|
+
onChange={(event) => setDrafts({ ...drafts, [key]: event.target.value })}
|
|
70
|
+
/>
|
|
71
|
+
</label>
|
|
72
|
+
<button type="button" onClick={() => saveSetting(key)}>Save</button>
|
|
73
|
+
</div>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { useAuth } from '../auth/AuthContext.jsx';
|
|
3
|
+
import Notice from '../components/Notice.jsx';
|
|
4
|
+
import styles from './Screen.module.css';
|
|
5
|
+
|
|
6
|
+
export default function Users() {
|
|
7
|
+
const { request } = useAuth();
|
|
8
|
+
const [users, setUsers] = useState([]);
|
|
9
|
+
const [error, setError] = useState('');
|
|
10
|
+
const [success, setSuccess] = useState('');
|
|
11
|
+
const [form, setForm] = useState({ email: '', role: 'member', password: 'password' });
|
|
12
|
+
const [loading, setLoading] = useState(true);
|
|
13
|
+
|
|
14
|
+
async function loadUsers() {
|
|
15
|
+
setLoading(true);
|
|
16
|
+
setError('');
|
|
17
|
+
try {
|
|
18
|
+
const data = await request('/users?limit=50&offset=0');
|
|
19
|
+
setUsers(data.items || []);
|
|
20
|
+
} catch (apiError) {
|
|
21
|
+
setError(apiError.status === 403 ? 'Not authorized to manage users.' : apiError.message);
|
|
22
|
+
} finally {
|
|
23
|
+
setLoading(false);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
loadUsers();
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
async function createUser(event) {
|
|
32
|
+
event.preventDefault();
|
|
33
|
+
setError('');
|
|
34
|
+
setSuccess('');
|
|
35
|
+
try {
|
|
36
|
+
await request('/users', { method: 'POST', body: form });
|
|
37
|
+
setForm({ email: '', role: 'member', password: 'password' });
|
|
38
|
+
setSuccess('User created.');
|
|
39
|
+
await loadUsers();
|
|
40
|
+
} catch (apiError) {
|
|
41
|
+
setError(apiError.status === 403 ? 'Not authorized to create users.' : apiError.message);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<section className={styles.screen}>
|
|
47
|
+
<header className={styles.header}>
|
|
48
|
+
<p>Administration</p>
|
|
49
|
+
<h1>Users</h1>
|
|
50
|
+
</header>
|
|
51
|
+
<Notice tone="error">{error}</Notice>
|
|
52
|
+
<Notice tone="success">{success}</Notice>
|
|
53
|
+
<form className={styles.inlineForm} onSubmit={createUser}>
|
|
54
|
+
<input
|
|
55
|
+
aria-label="Email"
|
|
56
|
+
placeholder="email@example.test"
|
|
57
|
+
type="email"
|
|
58
|
+
value={form.email}
|
|
59
|
+
onChange={(event) => setForm({ ...form, email: event.target.value })}
|
|
60
|
+
required
|
|
61
|
+
/>
|
|
62
|
+
<select
|
|
63
|
+
aria-label="Role"
|
|
64
|
+
value={form.role}
|
|
65
|
+
onChange={(event) => setForm({ ...form, role: event.target.value })}
|
|
66
|
+
>
|
|
67
|
+
<option value="member">member</option>
|
|
68
|
+
<option value="admin">admin</option>
|
|
69
|
+
<option value="owner">owner</option>
|
|
70
|
+
</select>
|
|
71
|
+
<input
|
|
72
|
+
aria-label="Password"
|
|
73
|
+
value={form.password}
|
|
74
|
+
onChange={(event) => setForm({ ...form, password: event.target.value })}
|
|
75
|
+
required
|
|
76
|
+
/>
|
|
77
|
+
<button type="submit">Create user</button>
|
|
78
|
+
</form>
|
|
79
|
+
{loading ? <p className={styles.muted}>Loading users...</p> : null}
|
|
80
|
+
<div className={styles.tableWrap}>
|
|
81
|
+
<table>
|
|
82
|
+
<thead>
|
|
83
|
+
<tr>
|
|
84
|
+
<th>Email</th>
|
|
85
|
+
<th>Role</th>
|
|
86
|
+
</tr>
|
|
87
|
+
</thead>
|
|
88
|
+
<tbody>
|
|
89
|
+
{users.map((user) => (
|
|
90
|
+
<tr key={user.id}>
|
|
91
|
+
<td>{user.email}</td>
|
|
92
|
+
<td>{user.role || 'unknown'}</td>
|
|
93
|
+
</tr>
|
|
94
|
+
))}
|
|
95
|
+
</tbody>
|
|
96
|
+
</table>
|
|
97
|
+
</div>
|
|
98
|
+
</section>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--bg: #0d1310;
|
|
3
|
+
--panel: #141b17;
|
|
4
|
+
--panel-raised: #1a231d;
|
|
5
|
+
--line: #2e3a31;
|
|
6
|
+
--line-bright: #46584c;
|
|
7
|
+
--lime: #d9ff66;
|
|
8
|
+
--lime-dim: #a8c94f;
|
|
9
|
+
--cream: #f4f1e6;
|
|
10
|
+
--muted: #7c8a7a;
|
|
11
|
+
--error: #ff6b5c;
|
|
12
|
+
--error-bg: #241512;
|
|
13
|
+
--success: #8ce99a;
|
|
14
|
+
--success-bg: #10201a;
|
|
15
|
+
--mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
* {
|
|
19
|
+
box-sizing: border-box;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
html {
|
|
23
|
+
color-scheme: dark;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
body {
|
|
27
|
+
margin: 0;
|
|
28
|
+
min-width: 320px;
|
|
29
|
+
font-family: var(--mono);
|
|
30
|
+
color: var(--cream);
|
|
31
|
+
background:
|
|
32
|
+
repeating-linear-gradient(
|
|
33
|
+
to bottom,
|
|
34
|
+
rgba(217, 255, 102, 0.025),
|
|
35
|
+
rgba(217, 255, 102, 0.025) 1px,
|
|
36
|
+
transparent 1px,
|
|
37
|
+
transparent 3px
|
|
38
|
+
),
|
|
39
|
+
var(--bg);
|
|
40
|
+
font-synthesis: none;
|
|
41
|
+
text-rendering: optimizeLegibility;
|
|
42
|
+
-webkit-font-smoothing: antialiased;
|
|
43
|
+
-moz-osx-font-smoothing: grayscale;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
button,
|
|
47
|
+
input,
|
|
48
|
+
select {
|
|
49
|
+
font: inherit;
|
|
50
|
+
color: inherit;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
button {
|
|
54
|
+
cursor: pointer;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
button:focus-visible,
|
|
58
|
+
input:focus-visible,
|
|
59
|
+
select:focus-visible {
|
|
60
|
+
outline: 2px solid var(--lime);
|
|
61
|
+
outline-offset: 2px;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
::selection {
|
|
65
|
+
background: var(--lime);
|
|
66
|
+
color: var(--bg);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
::-webkit-scrollbar {
|
|
70
|
+
width: 10px;
|
|
71
|
+
height: 10px;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
::-webkit-scrollbar-track {
|
|
75
|
+
background: var(--bg);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
::-webkit-scrollbar-thumb {
|
|
79
|
+
background: var(--line-bright);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
h1,
|
|
83
|
+
h2,
|
|
84
|
+
h3 {
|
|
85
|
+
font-family: var(--mono);
|
|
86
|
+
letter-spacing: 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@media (prefers-reduced-motion: reduce) {
|
|
90
|
+
*,
|
|
91
|
+
*::before,
|
|
92
|
+
*::after {
|
|
93
|
+
animation-duration: 1ms !important;
|
|
94
|
+
animation-iteration-count: 1 !important;
|
|
95
|
+
scroll-behavior: auto !important;
|
|
96
|
+
transition-duration: 1ms !important;
|
|
97
|
+
}
|
|
98
|
+
}
|