snice 3.7.0 → 3.9.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/README.md +2 -2
- package/bin/snice.js +11 -5
- package/bin/templates/CLAUDE.md +5 -5
- package/bin/templates/pwa/README.md +31 -16
- package/bin/templates/pwa/index.html +0 -1
- package/bin/templates/pwa/package.json +9 -2
- package/bin/templates/pwa/src/fetcher.ts +15 -0
- package/bin/templates/pwa/src/guards/auth.ts +6 -4
- package/bin/templates/pwa/src/middleware/auth.ts +7 -6
- package/bin/templates/pwa/src/middleware/error.ts +16 -5
- package/bin/templates/pwa/src/middleware/retry.ts +7 -3
- package/bin/templates/pwa/src/pages/dashboard.ts +4 -3
- package/bin/templates/pwa/src/pages/login.ts +7 -7
- package/bin/templates/pwa/src/pages/notifications.ts +2 -2
- package/bin/templates/pwa/src/pages/profile.ts +9 -8
- package/bin/templates/pwa/src/router.ts +8 -4
- package/bin/templates/pwa/src/types/auth.ts +1 -1
- package/bin/templates/pwa/tests/helpers/test-utils.ts +84 -0
- package/bin/templates/pwa/tests/middleware/auth.test.ts +67 -0
- package/bin/templates/pwa/tests/middleware/error.test.ts +105 -0
- package/bin/templates/pwa/tests/middleware/retry.test.ts +103 -0
- package/bin/templates/pwa/tests/services/auth.test.ts +89 -0
- package/bin/templates/pwa/tests/services/jwt.test.ts +76 -0
- package/bin/templates/pwa/tests/services/storage.test.ts +69 -0
- package/bin/templates/{social/vite.config.ts → pwa/vitest.config.ts} +12 -17
- package/dist/components/file-gallery/snice-file-gallery.d.ts +87 -0
- package/dist/components/file-gallery/snice-file-gallery.js +892 -0
- package/dist/components/file-gallery/snice-file-gallery.js.map +1 -0
- package/dist/components/file-gallery/snice-file-gallery.types.d.ts +72 -0
- package/dist/components/qr-reader/qr-decoder.d.ts +20 -0
- package/dist/components/qr-reader/qr-decoder.js +49 -0
- package/dist/components/qr-reader/qr-decoder.js.map +1 -0
- package/dist/components/qr-reader/qr-worker.d.ts +6 -0
- package/dist/components/qr-reader/qr-worker.js +64 -0
- package/dist/components/qr-reader/qr-worker.js.map +1 -0
- package/dist/components/qr-reader/snice-qr-reader.d.ts +39 -0
- package/dist/components/qr-reader/snice-qr-reader.js +436 -0
- package/dist/components/qr-reader/snice-qr-reader.js.map +1 -0
- package/dist/components/qr-reader/snice-qr-reader.types.d.ts +17 -0
- package/dist/components/qr-reader/zxing-reader.mjs +1582 -0
- package/dist/components/qr-reader/zxing-share.mjs +305 -0
- package/dist/components/qr-reader/zxing_reader.wasm +0 -0
- package/dist/components/zxing-reader-B3Rfebg9.js +1771 -0
- package/dist/components/zxing-reader-B3Rfebg9.js.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.esm.js +1 -1
- package/dist/index.iife.js +1 -1
- package/dist/symbols.cjs +1 -1
- package/dist/symbols.esm.js +1 -1
- package/dist/transitions.cjs +1 -1
- package/dist/transitions.esm.js +1 -1
- package/docs/ai/README.md +1 -1
- package/docs/ai/components/file-gallery.md +206 -0
- package/docs/ai/components/qr-reader.md +80 -0
- package/docs/ai/patterns.md +1 -1
- package/docs/components/file-gallery.md +692 -0
- package/docs/components/qr-reader.md +327 -0
- package/docs/routing.md +9 -8
- package/package.json +1 -1
- package/bin/templates/pwa/public/manifest.json +0 -24
- package/bin/templates/pwa/src/utils/fetch.ts +0 -39
- package/bin/templates/social/README.md +0 -42
- package/bin/templates/social/global.d.ts +0 -14
- package/bin/templates/social/index.html +0 -13
- package/bin/templates/social/package.json +0 -21
- package/bin/templates/social/public/vite.svg +0 -1
- package/bin/templates/social/src/main.ts +0 -33
- package/bin/templates/social/src/pages/feed-page.ts +0 -111
- package/bin/templates/social/src/pages/messages-page.ts +0 -102
- package/bin/templates/social/src/pages/not-found-page.ts +0 -46
- package/bin/templates/social/src/pages/profile-page.ts +0 -99
- package/bin/templates/social/src/pages/settings-page.ts +0 -119
- package/bin/templates/social/src/router.ts +0 -9
- package/bin/templates/social/src/styles/global.css +0 -156
- package/bin/templates/social/tsconfig.json +0 -22
package/README.md
CHANGED
|
@@ -416,10 +416,10 @@ When you modify the application context, call `update()` to notify all subscribe
|
|
|
416
416
|
```typescript
|
|
417
417
|
@page({ tag: 'login-page', routes: ['/login'] })
|
|
418
418
|
class LoginPage extends HTMLElement {
|
|
419
|
-
private ctx?: Context
|
|
419
|
+
private ctx?: Context;
|
|
420
420
|
|
|
421
421
|
@context()
|
|
422
|
-
handleContext(ctx: Context
|
|
422
|
+
handleContext(ctx: Context) {
|
|
423
423
|
this.ctx = ctx;
|
|
424
424
|
this.requestRender();
|
|
425
425
|
}
|
package/bin/snice.js
CHANGED
|
@@ -46,14 +46,12 @@ Options:
|
|
|
46
46
|
|
|
47
47
|
Templates:
|
|
48
48
|
base - Minimal starter with counter example (default)
|
|
49
|
-
social - Social media sample app showcasing components
|
|
50
49
|
pwa - Progressive Web App with auth, middleware, and live notifications
|
|
51
50
|
|
|
52
51
|
Examples:
|
|
53
52
|
snice create-app my-app
|
|
54
|
-
snice create-app my-app --template=
|
|
55
|
-
snice create-app --template=
|
|
56
|
-
npx snice create-app my-app --template=social
|
|
53
|
+
snice create-app my-app --template=pwa
|
|
54
|
+
npx snice create-app my-app --template=pwa
|
|
57
55
|
`);
|
|
58
56
|
}
|
|
59
57
|
|
|
@@ -91,7 +89,7 @@ function createApp(projectPath, template = 'base') {
|
|
|
91
89
|
// Check if template exists
|
|
92
90
|
if (!existsSync(templateDir)) {
|
|
93
91
|
console.error(`❌ Template "${template}" not found!`);
|
|
94
|
-
console.error(`Available templates: base,
|
|
92
|
+
console.error(`Available templates: base, pwa`);
|
|
95
93
|
process.exit(1);
|
|
96
94
|
}
|
|
97
95
|
|
|
@@ -106,6 +104,14 @@ function createApp(projectPath, template = 'base') {
|
|
|
106
104
|
writeFileSync(join(targetDir, 'CLAUDE.md'), claudeMdContent.replace(/\{\{projectName\}\}/g, projectName));
|
|
107
105
|
}
|
|
108
106
|
|
|
107
|
+
// Copy shared .gitignore
|
|
108
|
+
const gitignorePath = join(__dirname, 'templates', '.gitignore');
|
|
109
|
+
if (existsSync(gitignorePath)) {
|
|
110
|
+
console.log(` Creating .gitignore...`);
|
|
111
|
+
const gitignoreContent = readFileSync(gitignorePath, 'utf8');
|
|
112
|
+
writeFileSync(join(targetDir, '.gitignore'), gitignoreContent);
|
|
113
|
+
}
|
|
114
|
+
|
|
109
115
|
console.log(`\n✨ Project created successfully!\n`);
|
|
110
116
|
console.log('Next steps:');
|
|
111
117
|
|
package/bin/templates/CLAUDE.md
CHANGED
|
@@ -27,13 +27,13 @@ src/
|
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
**Separation of concerns:**
|
|
30
|
-
- **Pages** - Orchestrate elements, handle URLs
|
|
30
|
+
- **Pages** - Orchestrate elements, handle URLs, most logic happens here
|
|
31
31
|
- **Components** - Pure presentation, no business logic
|
|
32
|
-
- **Controllers** - Attach to elements, add behavior
|
|
33
|
-
- **Utils** - Pure helper functions
|
|
32
|
+
- **Controllers** - Attach to elements, add business behavior unsuitable for the page or components
|
|
34
33
|
- **Services** - Stateless business logic, API calls
|
|
35
34
|
- **Daemons** - Lifecycle-managed (WebSocket, P2P, intervals)
|
|
36
|
-
- **Middleware** - Composable functions (auth,
|
|
35
|
+
- **Middleware** - Composable functions (auth, retry)
|
|
36
|
+
- **Utils** - Pure helper functions
|
|
37
37
|
|
|
38
38
|
## Decorators
|
|
39
39
|
|
|
@@ -145,7 +145,7 @@ html`
|
|
|
145
145
|
|
|
146
146
|
**@request/@respond is NOT a service bus.** It's for element-to-element or element-to-controller communication only. Use utility functions for app-wide features.
|
|
147
147
|
|
|
148
|
-
**Guards receive Context
|
|
148
|
+
**Guards receive Context, not AppContext.** Check `ctx.application.property`, not `ctx.property`. Guards: `(ctx: Context) => boolean`
|
|
149
149
|
|
|
150
150
|
**Context must be mutated then updated.** After changing `ctx.application`, call `ctx.update()` to notify subscribers. Pages need `@context()` to get context reference.
|
|
151
151
|
|
|
@@ -53,24 +53,36 @@ src/
|
|
|
53
53
|
|
|
54
54
|
## Architecture Patterns
|
|
55
55
|
|
|
56
|
-
###
|
|
56
|
+
### Context-Aware Fetcher
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
Built-in middleware system with context access:
|
|
59
59
|
|
|
60
60
|
```typescript
|
|
61
|
-
|
|
62
|
-
import {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
61
|
+
// fetcher.ts - Setup
|
|
62
|
+
import { ContextAwareFetcher } from 'snice';
|
|
63
|
+
|
|
64
|
+
const fetcher = new ContextAwareFetcher();
|
|
65
|
+
fetcher.use('request', authMiddleware);
|
|
66
|
+
fetcher.use('response', errorMiddleware);
|
|
67
|
+
|
|
68
|
+
// Middleware with context access
|
|
69
|
+
export async function authMiddleware(
|
|
70
|
+
this: Context,
|
|
71
|
+
request: Request,
|
|
72
|
+
next: () => Promise<Response>
|
|
73
|
+
): Promise<Response> {
|
|
74
|
+
const token = getToken();
|
|
75
|
+
if (token) {
|
|
76
|
+
request.headers.set('Authorization', `Bearer ${token}`);
|
|
77
|
+
}
|
|
78
|
+
return next();
|
|
79
|
+
}
|
|
71
80
|
|
|
72
|
-
// Usage
|
|
73
|
-
|
|
81
|
+
// Usage in pages via ctx.fetch()
|
|
82
|
+
async loadData() {
|
|
83
|
+
const response = await this.ctx.fetch('/api/data');
|
|
84
|
+
const data = await response.json();
|
|
85
|
+
}
|
|
74
86
|
```
|
|
75
87
|
|
|
76
88
|
### Daemons for Lifecycle Management
|
|
@@ -112,9 +124,12 @@ export class DashboardPage extends HTMLElement {
|
|
|
112
124
|
Access shared state via context:
|
|
113
125
|
|
|
114
126
|
```typescript
|
|
127
|
+
import type { Principal } from './types/auth';
|
|
128
|
+
|
|
115
129
|
@context()
|
|
116
|
-
handleContext(ctx: Context
|
|
117
|
-
|
|
130
|
+
handleContext(ctx: Context) {
|
|
131
|
+
const principal = ctx.application.principal as Principal | undefined;
|
|
132
|
+
this.user = principal?.user;
|
|
118
133
|
}
|
|
119
134
|
```
|
|
120
135
|
|
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
<meta name="description" content="{{projectName}} - PWA built with Snice" />
|
|
7
7
|
<meta name="theme-color" content="#6366f1" />
|
|
8
8
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
9
|
-
<link rel="manifest" href="/manifest.json" />
|
|
10
9
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
|
11
10
|
<title>{{projectName}}</title>
|
|
12
11
|
</head>
|
|
@@ -5,9 +5,12 @@
|
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"dev": "vite",
|
|
8
|
-
"build": "
|
|
8
|
+
"build": "vite build",
|
|
9
9
|
"type-check": "tsc --noEmit",
|
|
10
|
-
"preview": "vite preview"
|
|
10
|
+
"preview": "vite preview",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"test:watch": "vitest",
|
|
13
|
+
"test:coverage": "vitest run --coverage"
|
|
11
14
|
},
|
|
12
15
|
"dependencies": {
|
|
13
16
|
"snice": "^3.0.0"
|
|
@@ -15,11 +18,15 @@
|
|
|
15
18
|
"devDependencies": {
|
|
16
19
|
"@types/node": "^20.0.0",
|
|
17
20
|
"@vite-pwa/assets-generator": "^0.2.4",
|
|
21
|
+
"@vitest/coverage-v8": "^1.0.4",
|
|
22
|
+
"@vitest/ui": "^1.0.4",
|
|
23
|
+
"happy-dom": "^12.10.3",
|
|
18
24
|
"terser": "^5.24.0",
|
|
19
25
|
"typescript": "^5.3.3",
|
|
20
26
|
"unplugin-swc": "^1.5.7",
|
|
21
27
|
"vite": "^5.0.10",
|
|
22
28
|
"vite-plugin-pwa": "^0.17.4",
|
|
29
|
+
"vitest": "^1.0.4",
|
|
23
30
|
"workbox-window": "^7.0.0"
|
|
24
31
|
}
|
|
25
32
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ContextAwareFetcher } from 'snice';
|
|
2
|
+
import { authMiddleware } from './middleware/auth';
|
|
3
|
+
import { errorMiddleware } from './middleware/error';
|
|
4
|
+
import { createRetryMiddleware } from './middleware/retry';
|
|
5
|
+
|
|
6
|
+
const fetcher = new ContextAwareFetcher();
|
|
7
|
+
|
|
8
|
+
// Add request middleware (runs before fetch)
|
|
9
|
+
fetcher.use('request', authMiddleware);
|
|
10
|
+
fetcher.use('request', createRetryMiddleware());
|
|
11
|
+
|
|
12
|
+
// Add response middleware (runs after fetch)
|
|
13
|
+
fetcher.use('response', errorMiddleware);
|
|
14
|
+
|
|
15
|
+
export { fetcher };
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { AppContext } from 'snice';
|
|
2
|
+
import type { Principal } from '../types/auth';
|
|
3
3
|
|
|
4
|
-
export function authGuard(
|
|
5
|
-
|
|
4
|
+
export function authGuard(appContext: AppContext): boolean {
|
|
5
|
+
const principal = appContext.principal as Principal | undefined;
|
|
6
|
+
|
|
7
|
+
if (!principal?.isAuthenticated) {
|
|
6
8
|
window.location.href = '#/login';
|
|
7
9
|
return false;
|
|
8
10
|
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Context } from 'snice';
|
|
2
2
|
import { getToken } from '../services/storage';
|
|
3
3
|
|
|
4
|
-
export async function authMiddleware(
|
|
4
|
+
export async function authMiddleware(
|
|
5
|
+
this: Context,
|
|
6
|
+
request: Request,
|
|
7
|
+
next: () => Promise<Response>
|
|
8
|
+
): Promise<Response> {
|
|
5
9
|
const token = getToken();
|
|
6
10
|
|
|
7
11
|
if (token) {
|
|
8
|
-
|
|
9
|
-
...options.headers,
|
|
10
|
-
'Authorization': `Bearer ${token}`,
|
|
11
|
-
};
|
|
12
|
+
request.headers.set('Authorization', `Bearer ${token}`);
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
return next();
|
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Context } from 'snice';
|
|
2
|
+
import type { Principal } from '../types/auth';
|
|
2
3
|
import { clearToken } from '../services/storage';
|
|
3
4
|
|
|
4
|
-
export async function errorMiddleware(
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
export async function errorMiddleware(
|
|
6
|
+
this: Context,
|
|
7
|
+
response: Response,
|
|
8
|
+
next: () => Promise<Response>
|
|
9
|
+
): Promise<Response> {
|
|
7
10
|
// Handle 401 unauthorized - token expired or invalid
|
|
8
11
|
if (response.status === 401) {
|
|
9
12
|
clearToken();
|
|
13
|
+
|
|
14
|
+
// Update context to reflect logged out state
|
|
15
|
+
if (this.application.principal) {
|
|
16
|
+
const principal = this.application.principal as Principal;
|
|
17
|
+
principal.user = null;
|
|
18
|
+
principal.isAuthenticated = false;
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
window.location.href = '#/login';
|
|
11
22
|
throw new Error('Unauthorized - redirecting to login');
|
|
12
23
|
}
|
|
@@ -21,5 +32,5 @@ export async function errorMiddleware(url: string, options: RequestInit, next: (
|
|
|
21
32
|
throw new Error(`Request failed with status ${response.status}`);
|
|
22
33
|
}
|
|
23
34
|
|
|
24
|
-
return
|
|
35
|
+
return next();
|
|
25
36
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Context } from 'snice';
|
|
2
2
|
|
|
3
|
-
export function
|
|
4
|
-
return async (
|
|
3
|
+
export function createRetryMiddleware(retries = 3, delay = 1000) {
|
|
4
|
+
return async function retryMiddleware(
|
|
5
|
+
this: Context,
|
|
6
|
+
_request: Request,
|
|
7
|
+
next: () => Promise<Response>
|
|
8
|
+
): Promise<Response> {
|
|
5
9
|
let lastError: Error;
|
|
6
10
|
|
|
7
11
|
for (let i = 0; i < retries; i++) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { page } from '../router';
|
|
2
2
|
import { render, styles, html, css, context } from 'snice';
|
|
3
3
|
import type { Placard, Context } from 'snice';
|
|
4
|
-
import type {
|
|
4
|
+
import type { Principal } from '../types/auth';
|
|
5
5
|
import { authGuard } from '../guards/auth';
|
|
6
6
|
|
|
7
7
|
const placard: Placard = {
|
|
@@ -17,8 +17,9 @@ export class DashboardPage extends HTMLElement {
|
|
|
17
17
|
userName = '';
|
|
18
18
|
|
|
19
19
|
@context()
|
|
20
|
-
handleContext(ctx: Context
|
|
21
|
-
|
|
20
|
+
handleContext(ctx: Context) {
|
|
21
|
+
const principal = ctx.application.principal as Principal | undefined;
|
|
22
|
+
this.userName = principal?.user?.name || 'User';
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
@render()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { page } from '../router';
|
|
2
2
|
import { render, styles, html, css, context } from 'snice';
|
|
3
3
|
import type { Placard, Context } from 'snice';
|
|
4
|
-
import type {
|
|
4
|
+
import type { Principal } from '../types/auth';
|
|
5
5
|
import { login } from '../services/auth';
|
|
6
6
|
|
|
7
7
|
const placard: Placard = {
|
|
@@ -17,10 +17,10 @@ export class LoginPage extends HTMLElement {
|
|
|
17
17
|
error = '';
|
|
18
18
|
loading = false;
|
|
19
19
|
|
|
20
|
-
private ctx?: Context
|
|
20
|
+
private ctx?: Context;
|
|
21
21
|
|
|
22
22
|
@context()
|
|
23
|
-
handleContext(ctx: Context
|
|
23
|
+
handleContext(ctx: Context) {
|
|
24
24
|
this.ctx = ctx;
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -33,10 +33,10 @@ export class LoginPage extends HTMLElement {
|
|
|
33
33
|
const result = await login({ email: this.email, password: this.password });
|
|
34
34
|
|
|
35
35
|
// Update context with new auth state
|
|
36
|
-
if (this.ctx) {
|
|
37
|
-
this.ctx.application.
|
|
38
|
-
|
|
39
|
-
|
|
36
|
+
if (this.ctx && this.ctx.application.principal) {
|
|
37
|
+
const principal = this.ctx.application.principal as Principal;
|
|
38
|
+
principal.user = result.user;
|
|
39
|
+
principal.isAuthenticated = true;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
window.location.href = '#/dashboard';
|
|
@@ -48,7 +48,7 @@ export class NotificationsPage extends HTMLElement {
|
|
|
48
48
|
this.notifications = [];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
removeNotification(id: string) {
|
|
52
52
|
this.notifications = this.notifications.filter(n => n.id !== id);
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -84,7 +84,7 @@ export class NotificationsPage extends HTMLElement {
|
|
|
84
84
|
key=${notification.id}
|
|
85
85
|
variant="${this.getVariant(notification.type)}"
|
|
86
86
|
dismissible
|
|
87
|
-
@dismiss=${() => this.
|
|
87
|
+
@dismiss=${() => this.removeNotification(notification.id)}
|
|
88
88
|
>
|
|
89
89
|
<strong>${notification.title}</strong>
|
|
90
90
|
<p>${notification.message}</p>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { page } from '../router';
|
|
2
2
|
import { render, styles, html, css, context } from 'snice';
|
|
3
3
|
import type { Placard, Context } from 'snice';
|
|
4
|
-
import type {
|
|
4
|
+
import type { Principal, User } from '../types/auth';
|
|
5
5
|
import { authGuard } from '../guards/auth';
|
|
6
6
|
import { logout } from '../services/auth';
|
|
7
7
|
|
|
@@ -16,22 +16,23 @@ const placard: Placard = {
|
|
|
16
16
|
@page({ tag: 'profile-page', routes: ['/profile'], guards: [authGuard], placard })
|
|
17
17
|
export class ProfilePage extends HTMLElement {
|
|
18
18
|
user: User | null = null;
|
|
19
|
-
private ctx?: Context
|
|
19
|
+
private ctx?: Context;
|
|
20
20
|
|
|
21
21
|
@context()
|
|
22
|
-
handleContext(ctx: Context
|
|
22
|
+
handleContext(ctx: Context) {
|
|
23
23
|
this.ctx = ctx;
|
|
24
|
-
|
|
24
|
+
const principal = ctx.application.principal as Principal | undefined;
|
|
25
|
+
this.user = principal?.user || null;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
async handleLogout() {
|
|
28
29
|
await logout();
|
|
29
30
|
|
|
30
31
|
// Update context to reflect logged out state
|
|
31
|
-
if (this.ctx) {
|
|
32
|
-
this.ctx.application.
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
if (this.ctx && this.ctx.application.principal) {
|
|
33
|
+
const principal = this.ctx.application.principal as Principal;
|
|
34
|
+
principal.user = null;
|
|
35
|
+
principal.isAuthenticated = false;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
window.location.href = '#/login';
|
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { Router } from 'snice';
|
|
2
|
-
import type {
|
|
2
|
+
import type { Principal } from './types/auth';
|
|
3
3
|
import { getUser } from './services/storage';
|
|
4
4
|
import { isAuthenticated } from './services/auth';
|
|
5
|
+
import { fetcher } from './fetcher';
|
|
5
6
|
|
|
6
|
-
const { page, initialize, navigate } = Router
|
|
7
|
+
const { page, initialize, navigate } = Router({
|
|
7
8
|
target: '#app',
|
|
8
9
|
type: 'hash',
|
|
9
10
|
layout: 'snice-layout',
|
|
11
|
+
fetcher,
|
|
10
12
|
context: {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
+
principal: {
|
|
14
|
+
user: getUser(),
|
|
15
|
+
isAuthenticated: isAuthenticated()
|
|
16
|
+
} as Principal
|
|
13
17
|
}
|
|
14
18
|
});
|
|
15
19
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wait for a specified number of milliseconds
|
|
3
|
+
*/
|
|
4
|
+
export async function waitFor(ms: number): Promise<void> {
|
|
5
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Wait for a condition to become true
|
|
10
|
+
*/
|
|
11
|
+
export async function waitUntil(
|
|
12
|
+
condition: () => boolean,
|
|
13
|
+
timeout = 1000,
|
|
14
|
+
interval = 10
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
const start = Date.now();
|
|
17
|
+
while (!condition()) {
|
|
18
|
+
if (Date.now() - start > timeout) {
|
|
19
|
+
throw new Error('Timeout waiting for condition');
|
|
20
|
+
}
|
|
21
|
+
await waitFor(interval);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create a container div and append it to document.body
|
|
27
|
+
*/
|
|
28
|
+
export function createContainer(id = 'app'): HTMLDivElement {
|
|
29
|
+
const container = document.createElement('div');
|
|
30
|
+
container.id = id;
|
|
31
|
+
document.body.appendChild(container);
|
|
32
|
+
return container;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Clean up the document body
|
|
37
|
+
*/
|
|
38
|
+
export function cleanup(): void {
|
|
39
|
+
document.body.innerHTML = '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mock localStorage for tests
|
|
44
|
+
*/
|
|
45
|
+
export function mockLocalStorage(): {
|
|
46
|
+
getItem: ReturnType<typeof vi.fn>;
|
|
47
|
+
setItem: ReturnType<typeof vi.fn>;
|
|
48
|
+
removeItem: ReturnType<typeof vi.fn>;
|
|
49
|
+
clear: ReturnType<typeof vi.fn>;
|
|
50
|
+
} {
|
|
51
|
+
const store: Record<string, string> = {};
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
getItem: vi.fn((key: string) => store[key] || null),
|
|
55
|
+
setItem: vi.fn((key: string, value: string) => {
|
|
56
|
+
store[key] = value;
|
|
57
|
+
}),
|
|
58
|
+
removeItem: vi.fn((key: string) => {
|
|
59
|
+
delete store[key];
|
|
60
|
+
}),
|
|
61
|
+
clear: vi.fn(() => {
|
|
62
|
+
Object.keys(store).forEach(key => delete store[key]);
|
|
63
|
+
}),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Mock fetch for tests
|
|
69
|
+
*/
|
|
70
|
+
export function mockFetch(
|
|
71
|
+
response: unknown = {},
|
|
72
|
+
status = 200,
|
|
73
|
+
ok = true
|
|
74
|
+
): ReturnType<typeof vi.fn> {
|
|
75
|
+
return vi.fn(() =>
|
|
76
|
+
Promise.resolve({
|
|
77
|
+
ok,
|
|
78
|
+
status,
|
|
79
|
+
json: () => Promise.resolve(response),
|
|
80
|
+
text: () => Promise.resolve(JSON.stringify(response)),
|
|
81
|
+
headers: new Headers(),
|
|
82
|
+
} as Response)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { Context } from 'snice';
|
|
3
|
+
import { authMiddleware } from '../../src/middleware/auth';
|
|
4
|
+
import * as storage from '../../src/services/storage';
|
|
5
|
+
|
|
6
|
+
describe('Auth Middleware', () => {
|
|
7
|
+
let mockContext: Context;
|
|
8
|
+
let mockRequest: Request;
|
|
9
|
+
let mockNext: ReturnType<typeof vi.fn>;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
localStorage.clear();
|
|
13
|
+
vi.clearAllMocks();
|
|
14
|
+
|
|
15
|
+
// Create mock context
|
|
16
|
+
mockContext = {
|
|
17
|
+
application: {
|
|
18
|
+
principal: {
|
|
19
|
+
user: null,
|
|
20
|
+
isAuthenticated: false,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
navigation: {
|
|
24
|
+
route: '/',
|
|
25
|
+
params: {},
|
|
26
|
+
},
|
|
27
|
+
update: vi.fn(),
|
|
28
|
+
} as unknown as Context;
|
|
29
|
+
|
|
30
|
+
// Create mock request
|
|
31
|
+
mockRequest = new Request('https://api.example.com/data', {
|
|
32
|
+
headers: new Headers(),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Create mock next function
|
|
36
|
+
const mockResponse = new Response('{}', { status: 200 });
|
|
37
|
+
mockNext = vi.fn(() => Promise.resolve(mockResponse));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should add Authorization header when token exists', async () => {
|
|
41
|
+
const token = 'test-token-123';
|
|
42
|
+
storage.setToken(token);
|
|
43
|
+
|
|
44
|
+
await authMiddleware.call(mockContext, mockRequest, mockNext);
|
|
45
|
+
|
|
46
|
+
expect(mockRequest.headers.get('Authorization')).toBe(`Bearer ${token}`);
|
|
47
|
+
expect(mockNext).toHaveBeenCalled();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should not add Authorization header when no token exists', async () => {
|
|
51
|
+
await authMiddleware.call(mockContext, mockRequest, mockNext);
|
|
52
|
+
|
|
53
|
+
expect(mockRequest.headers.get('Authorization')).toBeNull();
|
|
54
|
+
expect(mockNext).toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should call next() and return response', async () => {
|
|
58
|
+
const token = 'test-token-123';
|
|
59
|
+
storage.setToken(token);
|
|
60
|
+
|
|
61
|
+
const response = await authMiddleware.call(mockContext, mockRequest, mockNext);
|
|
62
|
+
|
|
63
|
+
expect(mockNext).toHaveBeenCalledTimes(1);
|
|
64
|
+
expect(response).toBeInstanceOf(Response);
|
|
65
|
+
expect(response.status).toBe(200);
|
|
66
|
+
});
|
|
67
|
+
});
|