create-freakpunch 0.1.0 → 0.2.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 +24 -23
- package/bin/create-freakpunch.mjs +6 -6
- package/files/README.md +19 -15
- package/files/app.config.ts +50 -58
- package/files/locales.ts +10 -0
- package/files/registry.ts +17 -0
- package/package.json +23 -23
- package/template/BACKEND.md +108 -86
- package/template/CLAUDE.md +15 -10
- package/template/design/COMPONENTS.md +1 -1
- package/template/design/DESIGN.md +3 -3
- package/template/design/PATTERNS.md +5 -5
- package/template/index.html +1 -1
- package/template/src/app.config.ts +50 -61
- package/template/src/app.roles.ts +24 -24
- package/template/src/core/api/demo.ts +8 -0
- package/template/src/core/audit/audit.service.ts +3 -34
- package/template/src/core/auth/auth.service.ts +25 -14
- package/template/src/core/auth/local-credentials.ts +46 -0
- package/template/src/core/auth/permissions.ts +1 -1
- package/template/src/core/company/company.service.ts +14 -12
- package/template/src/core/company/use-app-name.ts +7 -0
- package/template/src/core/components/page.tsx +4 -3
- package/template/src/core/config/runtime.ts +16 -14
- package/template/src/core/config/types.ts +3 -2
- package/template/src/core/layout/app-sidebar.tsx +19 -2
- package/template/src/core/notifications/notifications.service.ts +10 -115
- package/template/src/core/notifications/types.ts +5 -0
- package/template/src/core/setup/setup-gate.tsx +20 -0
- package/template/src/core/setup/setup.service.ts +101 -0
- package/template/src/core/users/users.service.ts +2 -2
- package/template/src/locales/en/auth.ts +2 -1
- package/template/src/locales/en/common.ts +1 -0
- package/template/src/locales/en/company.ts +3 -1
- package/template/src/locales/en/dashboard.ts +5 -0
- package/template/src/locales/en/setup.ts +45 -0
- package/template/src/locales/index.ts +4 -4
- package/template/src/locales/tr/auth.ts +2 -1
- package/template/src/locales/tr/common.ts +1 -0
- package/template/src/locales/tr/company.ts +3 -1
- package/template/src/locales/tr/dashboard.ts +5 -0
- package/template/src/locales/tr/setup.ts +49 -0
- package/template/src/modules/locales.ts +0 -4
- package/template/src/modules/registry.ts +17 -19
- package/template/src/pages/dashboard/dashboard-page.tsx +28 -243
- package/template/src/pages/dashboard/quick-actions.tsx +37 -0
- package/template/src/pages/login-page.tsx +26 -14
- package/template/src/pages/not-found-page.tsx +1 -2
- package/template/src/pages/settings/account/account.service.ts +22 -41
- package/template/src/pages/settings/account/notifications-page.tsx +3 -3
- package/template/src/pages/settings/company/general-page.tsx +6 -4
- package/template/src/pages/settings/company/integrations/integrations.service.ts +8 -28
- package/template/src/pages/settings/company/modules-page.tsx +10 -2
- package/template/src/pages/settings/company/roles-page.tsx +2 -1
- package/template/src/pages/setup-page.tsx +387 -0
- package/template/src/router.tsx +9 -4
- package/template/tools/new-module.mjs +90 -90
- package/template/tools/templates/module/index.ts.tpl +44 -37
- package/template/tools/templates/module/locales/en.ts.tpl +51 -45
- package/template/tools/templates/module/locales/tr.ts.tpl +55 -49
- package/template/{src/modules/products/pages/product-detail-page.tsx → tools/templates/module/pages/__id__-detail-page.tsx.tpl} +103 -108
- package/template/tools/templates/module/pages/__id__-form-page.tsx.tpl +121 -121
- package/template/tools/templates/module/pages/__id__-list-page.tsx.tpl +234 -237
- package/template/vite.config.ts +6 -3
- package/template/src/core/users/users.mock.ts +0 -24
- package/template/src/locales/en/landing.ts +0 -15
- package/template/src/locales/tr/landing.ts +0 -19
- package/template/src/modules/products/api/products.mock.ts +0 -46
- package/template/src/modules/products/api/products.service.ts +0 -84
- package/template/src/modules/products/components/product-status.tsx +0 -21
- package/template/src/modules/products/hooks.ts +0 -73
- package/template/src/modules/products/index.ts +0 -50
- package/template/src/modules/products/locales/en.ts +0 -78
- package/template/src/modules/products/locales/tr.ts +0 -82
- package/template/src/modules/products/pages/product-form-page.tsx +0 -211
- package/template/src/modules/products/pages/products-list-page.tsx +0 -286
- package/template/src/modules/products/types.ts +0 -61
- package/template/src/pages/dashboard/dashboard.service.ts +0 -92
- package/template/src/pages/landing-page.tsx +0 -79
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Design system
|
|
2
2
|
|
|
3
|
-
The official design language of
|
|
3
|
+
The official design language of this admin panel. Every screen in every project built
|
|
4
4
|
from this template follows it. Components and patterns live in [COMPONENTS.md](COMPONENTS.md) and
|
|
5
5
|
[PATTERNS.md](PATTERNS.md). The single source of truth for values is `src/index.css`.
|
|
6
6
|
|
|
@@ -25,7 +25,7 @@ from this template follows it. Components and patterns live in [COMPONENTS.md](C
|
|
|
25
25
|
│ (on the canvas) │ top bar: sidebar toggle · breadcrumbs · tools │
|
|
26
26
|
│ │────────────────────────────────────────────────│
|
|
27
27
|
│ ▢ Dashboard │ Page title [actions] │
|
|
28
|
-
│ ▣
|
|
28
|
+
│ ▣ Orders ←pill│ description │
|
|
29
29
|
│ │ ┌ card ┐ ┌ card ┐ │
|
|
30
30
|
│ │ └──────┘ └──────┘ │
|
|
31
31
|
│ [user card] └────────────────────────────────────────────────┘
|
|
@@ -61,7 +61,7 @@ from this template follows it. Components and patterns live in [COMPONENTS.md](C
|
|
|
61
61
|
(`text-[13px]` nav items, `text-[10.5px]`/`text-[11px]` small meta) are the only allowed ones.
|
|
62
62
|
- Uppercase `label-caps` is for **short labels only** (one to three words), never for sentences.
|
|
63
63
|
Numbers that line up (tables, KPIs, counters, times) use `tabular-nums`.
|
|
64
|
-
- Headings use sentence case ("New
|
|
64
|
+
- Headings use sentence case ("New order", not "New Order"), in both languages.
|
|
65
65
|
|
|
66
66
|
## Spacing
|
|
67
67
|
|
|
@@ -5,10 +5,10 @@ for each pattern.
|
|
|
5
5
|
|
|
6
6
|
## List page
|
|
7
7
|
|
|
8
|
-
Reference:
|
|
8
|
+
Reference: the list page `npm run new-module` generates, and `src/pages/settings/company/users-page.tsx`.
|
|
9
9
|
|
|
10
10
|
```
|
|
11
|
-
Page title="
|
|
11
|
+
Page title="Orders" description="…" actions={<primary create button>}
|
|
12
12
|
└ DataTable
|
|
13
13
|
├ toolbar: TableSearch · FilterSelect × n · ClearFiltersButton (only when filters are active)
|
|
14
14
|
├ columns: identity column first (icon/avatar + name link + muted sub-line),
|
|
@@ -24,10 +24,10 @@ Page title="Products" description="…" actions={<primary create button>}
|
|
|
24
24
|
|
|
25
25
|
## Form page (create / edit)
|
|
26
26
|
|
|
27
|
-
Reference: `
|
|
27
|
+
Reference: the form page `npm run new-module` generates.
|
|
28
28
|
|
|
29
29
|
```
|
|
30
|
-
Page title="New
|
|
30
|
+
Page title="New order" description="…"
|
|
31
31
|
└ FormLayout submitLabel onCancel (saveBar="always")
|
|
32
32
|
├ FormSection "Details" — most important fields first, autofocus on create
|
|
33
33
|
├ FormSection "Organization"
|
|
@@ -41,7 +41,7 @@ Page title="New product" description="…"
|
|
|
41
41
|
|
|
42
42
|
## Detail page
|
|
43
43
|
|
|
44
|
-
Reference: `
|
|
44
|
+
Reference: the detail page `npm run new-module` generates.
|
|
45
45
|
|
|
46
46
|
```
|
|
47
47
|
Page title={name} description={muted id} leading={icon tile} actions={Delete (destructive) · Edit (primary)}
|
package/template/index.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
7
|
<meta name="robots" content="noindex, nofollow" />
|
|
8
|
-
<meta name="description" content="
|
|
8
|
+
<meta name="description" content="Admin panel" />
|
|
9
9
|
<meta name="color-scheme" content="light dark" />
|
|
10
10
|
<title>freakpunch</title>
|
|
11
11
|
<script>
|
|
@@ -1,61 +1,50 @@
|
|
|
1
|
-
import type { AppConfig, AuthMode } from '@/core/config/types'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
{ id: '
|
|
15
|
-
{ id: '
|
|
16
|
-
{ id: '
|
|
17
|
-
],
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
auth: {
|
|
52
|
-
mode: (import.meta.env.VITE_AUTH_MODE ?? 'mock') as AuthMode,
|
|
53
|
-
csrfUrl: import.meta.env.VITE_CSRF_URL ?? '/sanctum/csrf-cookie',
|
|
54
|
-
},
|
|
55
|
-
|
|
56
|
-
/** Default ISO 4217 currency for formatting (Settings → Company → General can override it). */
|
|
57
|
-
currency: 'USD',
|
|
58
|
-
|
|
59
|
-
/** Real REST API base URL; mock services ignore it. */
|
|
60
|
-
apiBaseUrl: import.meta.env.VITE_API_URL ?? '/api',
|
|
61
|
-
} satisfies AppConfig
|
|
1
|
+
import type { AppConfig, AuthMode } from '@/core/config/types'
|
|
2
|
+
|
|
3
|
+
/** This panel's settings. The company name and logo come from the setup wizard and Settings. */
|
|
4
|
+
export const appConfig = {
|
|
5
|
+
name: 'Admin',
|
|
6
|
+
/** A preset key from src/core/theme/brand.ts, or a custom { color, foreground } pair. */
|
|
7
|
+
brand: 'indigo',
|
|
8
|
+
|
|
9
|
+
workspaces: [{ id: 'main', name: 'Admin' }],
|
|
10
|
+
|
|
11
|
+
/** Sidebar group order; labels are translation keys. Modules attach nav items to these ids. */
|
|
12
|
+
navGroups: [
|
|
13
|
+
{ id: 'overview', label: 'common:nav.groups.overview' },
|
|
14
|
+
{ id: 'catalog', label: 'common:nav.groups.catalog' },
|
|
15
|
+
{ id: 'content', label: 'common:nav.groups.content' },
|
|
16
|
+
{ id: 'system', label: 'common:nav.groups.system' },
|
|
17
|
+
],
|
|
18
|
+
|
|
19
|
+
/** Turn modules on/off. `npm run new-module` adds them here. */
|
|
20
|
+
modules: {
|
|
21
|
+
// fp:modules
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
/** No marketing page: `/` opens the panel. */
|
|
25
|
+
landing: false,
|
|
26
|
+
|
|
27
|
+
/** Built-in areas, off until a project needs them. Setting one to true adds its pages and menu items. */
|
|
28
|
+
features: {
|
|
29
|
+
notifications: false,
|
|
30
|
+
users: false,
|
|
31
|
+
roles: false,
|
|
32
|
+
securityPolicies: false,
|
|
33
|
+
modules: false,
|
|
34
|
+
integrations: false,
|
|
35
|
+
auditLog: false,
|
|
36
|
+
dangerZone: false,
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
/** Default ISO 4217 currency for the setup wizard. */
|
|
40
|
+
currency: 'USD',
|
|
41
|
+
|
|
42
|
+
/** Real REST API base URL. */
|
|
43
|
+
apiBaseUrl: import.meta.env.VITE_API_URL ?? '/api',
|
|
44
|
+
|
|
45
|
+
/** mock (no backend, data stays in this browser) | bearer (JWT) | cookie (Laravel Sanctum). See BACKEND.md. */
|
|
46
|
+
auth: {
|
|
47
|
+
mode: (import.meta.env.VITE_AUTH_MODE ?? 'mock') as AuthMode,
|
|
48
|
+
csrfUrl: import.meta.env.VITE_CSRF_URL ?? '/sanctum/csrf-cookie',
|
|
49
|
+
},
|
|
50
|
+
} satisfies AppConfig
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
import type { Permission } from '@/core/auth/permissions'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Project-specific access rules. The Owner and Admin automatically get every
|
|
5
|
-
* custom permission; other roles get what is listed under `permissions`.
|
|
6
|
-
* Labels live here (EN + TR) so core translation files stay untouched.
|
|
7
|
-
*
|
|
8
|
-
* Example:
|
|
9
|
-
* customPermissions = [{ id: '
|
|
10
|
-
* customRoles = {
|
|
11
|
-
*
|
|
12
|
-
* label: { en: '
|
|
13
|
-
* description: { en: '
|
|
14
|
-
* permissions: ['dashboard.view', '
|
|
15
|
-
* },
|
|
16
|
-
* }
|
|
17
|
-
*/
|
|
18
|
-
export type LocalizedText = { en: string; tr: string }
|
|
19
|
-
|
|
20
|
-
export type CustomRole = { label: LocalizedText; description: LocalizedText; permissions: readonly Permission[] }
|
|
21
|
-
|
|
22
|
-
export const customPermissions = [] as const satisfies readonly { id: string; label: LocalizedText }[]
|
|
23
|
-
|
|
24
|
-
export const customRoles = {} satisfies Record<string, CustomRole>
|
|
1
|
+
import type { Permission } from '@/core/auth/permissions'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Project-specific access rules. The Owner and Admin automatically get every
|
|
5
|
+
* custom permission; other roles get what is listed under `permissions`.
|
|
6
|
+
* Labels live here (EN + TR) so core translation files stay untouched.
|
|
7
|
+
*
|
|
8
|
+
* Example:
|
|
9
|
+
* customPermissions = [{ id: 'orders.refund', label: { en: 'Refund orders', tr: 'Sipariş iade et' } }] as const
|
|
10
|
+
* customRoles = {
|
|
11
|
+
* support: {
|
|
12
|
+
* label: { en: 'Support', tr: 'Destek' },
|
|
13
|
+
* description: { en: 'Handles orders and refunds.', tr: 'Siparişleri ve iadeleri yönetir.' },
|
|
14
|
+
* permissions: ['dashboard.view', 'orders.view', 'orders.refund'],
|
|
15
|
+
* },
|
|
16
|
+
* }
|
|
17
|
+
*/
|
|
18
|
+
export type LocalizedText = { en: string; tr: string }
|
|
19
|
+
|
|
20
|
+
export type CustomRole = { label: LocalizedText; description: LocalizedText; permissions: readonly Permission[] }
|
|
21
|
+
|
|
22
|
+
export const customPermissions = [] as const satisfies readonly { id: string; label: LocalizedText }[]
|
|
23
|
+
|
|
24
|
+
export const customRoles = {} satisfies Record<string, CustomRole>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { isDemo } from '@/core/config/runtime'
|
|
2
|
+
|
|
3
|
+
// A glob, not an import: src/demo doesn't exist in installed copies, and this still builds without it.
|
|
4
|
+
const files = import.meta.glob<{ default: Record<string, unknown> }>('/src/demo/index.ts', { eager: true })
|
|
5
|
+
const data = isDemo ? Object.values(files)[0]?.default : undefined
|
|
6
|
+
|
|
7
|
+
/** Sample content by name, or undefined in real installs. */
|
|
8
|
+
export const demoSeed = <T>(name: string) => data?.[name] as T | undefined
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { demoSeed } from '@/core/api/demo'
|
|
2
|
+
import { createMockCollection, delay, paginate } from '@/core/api/mock'
|
|
2
3
|
import type { ListParams, Paginated } from '@/core/api/types'
|
|
3
4
|
import { authStore } from '@/core/auth/auth-store'
|
|
4
5
|
|
|
@@ -52,40 +53,8 @@ export interface AuditService {
|
|
|
52
53
|
|
|
53
54
|
const rangeMs: Record<string, number> = { '24h': 86_400_000, '7d': 7 * 86_400_000, '30d': 30 * 86_400_000 }
|
|
54
55
|
|
|
55
|
-
function seed(): AuditEntry[] {
|
|
56
|
-
const rand = seededRandom(2024)
|
|
57
|
-
const now = Date.now()
|
|
58
|
-
const actors = [
|
|
59
|
-
{ id: 'u_owner', name: 'Deniz Aydın', email: 'owner@freakpunch.site' },
|
|
60
|
-
{ id: 'u_admin', name: 'Maya Chen', email: 'admin@freakpunch.site' },
|
|
61
|
-
{ id: 'u_0006', name: 'Can Yılmaz', email: 'can.yilmaz@freakpunch.site' },
|
|
62
|
-
]
|
|
63
|
-
const samples: Pick<AuditEntry, 'action' | 'target' | 'detail'>[] = [
|
|
64
|
-
{ action: 'generalUpdated', target: 'freakpunch' },
|
|
65
|
-
{ action: 'brandingUpdated', target: 'freakpunch' },
|
|
66
|
-
{ action: 'userInvited', target: 'lena.fischer@freakpunch.site', detail: { role: 'editor' } },
|
|
67
|
-
{ action: 'roleChanged', target: 'burak.ozturk@freakpunch.site', detail: { from: 'editor', to: 'viewer' } },
|
|
68
|
-
{ action: 'userDeactivated', target: 'sofia.rossi@freakpunch.site' },
|
|
69
|
-
{ action: 'integrationUpdated', target: 'SMTP' },
|
|
70
|
-
{ action: 'webhookCreated', target: 'https://hooks.example.com/orders' },
|
|
71
|
-
{ action: 'apiKeyCreated', target: 'Order sync' },
|
|
72
|
-
{ action: 'securityUpdated', target: 'freakpunch' },
|
|
73
|
-
{ action: 'inviteResent', target: 'mert.kaya@freakpunch.site' },
|
|
74
|
-
]
|
|
75
|
-
return Array.from({ length: 28 }, (_, i) => {
|
|
76
|
-
const sample = samples[Math.floor(rand() * samples.length)]!
|
|
77
|
-
return {
|
|
78
|
-
id: `a_${String(i + 1).padStart(4, '0')}`,
|
|
79
|
-
at: new Date(now - (i * 26 + rand() * 20) * 3_600_000).toISOString(),
|
|
80
|
-
actor: actors[Math.floor(rand() * actors.length)]!,
|
|
81
|
-
ip: `88.241.${Math.floor(rand() * 255)}.${Math.floor(rand() * 255)}`,
|
|
82
|
-
...sample,
|
|
83
|
-
}
|
|
84
|
-
})
|
|
85
|
-
}
|
|
86
|
-
|
|
87
56
|
function createMockAuditService(): AuditService {
|
|
88
|
-
const db = createMockCollection('audit',
|
|
57
|
+
const db = createMockCollection('audit', () => demoSeed<() => AuditEntry[]>('audit')?.() ?? [])
|
|
89
58
|
|
|
90
59
|
return {
|
|
91
60
|
async list(params) {
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { ApiError, http } from '@/core/api/http'
|
|
2
2
|
import { delay } from '@/core/api/mock'
|
|
3
|
-
import { authMode, csrfUrl } from '@/core/config/runtime'
|
|
3
|
+
import { authMode, csrfUrl, isDemo } from '@/core/config/runtime'
|
|
4
4
|
import type { User } from '@/core/users/types'
|
|
5
5
|
import { findUserByEmail, userErrors, usersService } from '@/core/users/users.service'
|
|
6
6
|
import { authStore } from './auth-store'
|
|
7
|
+
import { localCredentials } from './local-credentials'
|
|
7
8
|
import type { AuthService, Session } from './types'
|
|
8
9
|
|
|
9
10
|
const SESSION_TTL = 1000 * 60 * 60 * 24 * 7 // 7 days
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
function startSession(user: User): Session {
|
|
11
|
+
export function startLocalSession(user: User): Session {
|
|
13
12
|
const session: Session = {
|
|
14
13
|
user: { ...user, lastActiveAt: new Date().toISOString() },
|
|
15
14
|
token: `mock.${user.id}.${Date.now().toString(36)}`,
|
|
@@ -20,17 +19,24 @@ function startSession(user: User): Session {
|
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* No backend yet. In the public demo any password works: a known email signs in as
|
|
23
|
+
* that user, an unknown one as the Owner. In a real install the password from the
|
|
24
|
+
* setup wizard is checked (see local-credentials: this browser only, not security).
|
|
26
25
|
*/
|
|
27
26
|
const mockAuthService: AuthService = {
|
|
28
|
-
async login({ email }) {
|
|
27
|
+
async login({ email, password }) {
|
|
29
28
|
await delay(400, 700)
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
if (isDemo) {
|
|
30
|
+
const users = await usersService.listActive()
|
|
31
|
+
const user = (await findUserByEmail(email)) ?? users.find((u) => u.role === 'owner')
|
|
32
|
+
if (!user) throw new ApiError(404, userErrors.inactive)
|
|
33
|
+
if (user.status !== 'active') throw new ApiError(403, userErrors.inactive)
|
|
34
|
+
return startLocalSession(user)
|
|
35
|
+
}
|
|
36
|
+
const user = await findUserByEmail(email)
|
|
37
|
+
if (!user || !(await localCredentials.verify(user.id, password))) throw new ApiError(401, 'invalid_credentials')
|
|
32
38
|
if (user.status !== 'active') throw new ApiError(403, userErrors.inactive)
|
|
33
|
-
return
|
|
39
|
+
return startLocalSession(user)
|
|
34
40
|
},
|
|
35
41
|
|
|
36
42
|
async logout() {
|
|
@@ -43,16 +49,21 @@ const mockAuthService: AuthService = {
|
|
|
43
49
|
// Project-defined roles have no seeded demo user: keep the identity, change the role.
|
|
44
50
|
const user = users.find((u) => u.role === role) ?? (current && { ...current, role })
|
|
45
51
|
if (!user) throw new ApiError(404, userErrors.inactive)
|
|
46
|
-
return
|
|
52
|
+
return startLocalSession(user)
|
|
47
53
|
},
|
|
48
54
|
}
|
|
49
55
|
|
|
50
|
-
type AuthResponse = { user: User; token?: string; expiresAt?: number }
|
|
56
|
+
export type AuthResponse = { user: User; token?: string; expiresAt?: number }
|
|
57
|
+
|
|
58
|
+
/** Cookie mode: the server sets the XSRF cookie before the first write. */
|
|
59
|
+
export async function fetchCsrfCookie() {
|
|
60
|
+
if (authMode === 'cookie' && csrfUrl) await fetch(csrfUrl, { credentials: 'include' })
|
|
61
|
+
}
|
|
51
62
|
|
|
52
63
|
/** Real backend (bearer or cookie mode); the contract is in BACKEND.md. */
|
|
53
64
|
const restAuthService: AuthService = {
|
|
54
65
|
async login(credentials) {
|
|
55
|
-
|
|
66
|
+
await fetchCsrfCookie()
|
|
56
67
|
const { user, token, expiresAt } = await http.post<AuthResponse>('/auth/login', credentials)
|
|
57
68
|
const session: Session = { user, token, expiresAt: expiresAt ?? Date.now() + SESSION_TTL }
|
|
58
69
|
authStore.set(session)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { ApiError } from '@/core/api/http'
|
|
2
|
+
import { storage } from '@/core/lib/storage'
|
|
3
|
+
|
|
4
|
+
const KEY = 'mock:credentials'
|
|
5
|
+
const ITERATIONS = 210_000
|
|
6
|
+
|
|
7
|
+
type StoredPassword = { salt: string; hash: string }
|
|
8
|
+
|
|
9
|
+
const toHex = (bytes: ArrayBuffer | Uint8Array) =>
|
|
10
|
+
Array.from(new Uint8Array(bytes as ArrayBuffer), (b) => b.toString(16).padStart(2, '0')).join('')
|
|
11
|
+
const fromHex = (hex: string) => Uint8Array.from(hex.match(/.{2}/g) ?? [], (h) => parseInt(h, 16))
|
|
12
|
+
|
|
13
|
+
async function derive(password: string, salt: Uint8Array) {
|
|
14
|
+
// crypto.subtle only exists on https and localhost.
|
|
15
|
+
if (!crypto.subtle) throw new ApiError(400, 'insecure_context')
|
|
16
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits'])
|
|
17
|
+
const bits = await crypto.subtle.deriveBits(
|
|
18
|
+
{ name: 'PBKDF2', hash: 'SHA-256', salt: salt as BufferSource, iterations: ITERATIONS },
|
|
19
|
+
key,
|
|
20
|
+
256,
|
|
21
|
+
)
|
|
22
|
+
return toHex(bits)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const readAll = () => storage.get<Record<string, StoredPassword>>(KEY) ?? {}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Passwords for local mode (no backend yet). Only a salted hash is kept, but it
|
|
29
|
+
* lives in this browser, so this keeps the panel usable while building and is
|
|
30
|
+
* NOT real security. A real backend owns passwords.
|
|
31
|
+
*/
|
|
32
|
+
export const localCredentials = {
|
|
33
|
+
/** Whether any account exists on this browser. */
|
|
34
|
+
any: () => Object.keys(readAll()).length > 0,
|
|
35
|
+
|
|
36
|
+
async set(userId: string, password: string) {
|
|
37
|
+
const salt = crypto.getRandomValues(new Uint8Array(16))
|
|
38
|
+
storage.set(KEY, { ...readAll(), [userId]: { salt: toHex(salt), hash: await derive(password, salt) } })
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
async verify(userId: string, password: string) {
|
|
42
|
+
const stored = readAll()[userId]
|
|
43
|
+
if (!stored) return false
|
|
44
|
+
return (await derive(password, fromHex(stored.salt))) === stored.hash
|
|
45
|
+
},
|
|
46
|
+
}
|
|
@@ -36,7 +36,7 @@ export const corePermissions = [
|
|
|
36
36
|
] as const
|
|
37
37
|
export type CorePermission = (typeof corePermissions)[number]
|
|
38
38
|
|
|
39
|
-
/** Project-specific permissions from src/app.roles.ts, e.g. `
|
|
39
|
+
/** Project-specific permissions from src/app.roles.ts, e.g. `orders.refund`. */
|
|
40
40
|
export type CustomPermission = (typeof customPermissions)[number]['id']
|
|
41
41
|
|
|
42
42
|
export type Permission = CorePermission | ModulePermission | CustomPermission
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { appConfig } from '@/app.config'
|
|
2
|
+
import { demoSeed } from '@/core/api/demo'
|
|
2
3
|
import { ApiError } from '@/core/api/http'
|
|
3
4
|
import { createMockDocument, delay } from '@/core/api/mock'
|
|
4
5
|
import { auditService } from '@/core/audit/audit.service'
|
|
5
6
|
import { authStore } from '@/core/auth/auth-store'
|
|
6
7
|
import { storage } from '@/core/lib/storage'
|
|
7
8
|
import { defaultPasswordPolicy } from './password-policy'
|
|
8
|
-
import type { CompanyBranding, CompanyGeneral, CompanySettings, SecurityPolicies } from './types'
|
|
9
|
+
import type { CompanyBranding, CompanyGeneral, CompanySettings, Currency, SecurityPolicies } from './types'
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Company-wide settings. A real backend implements this with `http`
|
|
@@ -20,20 +21,21 @@ export interface CompanyService {
|
|
|
20
21
|
deleteCompany(confirmName: string): Promise<void>
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
/** Empty company settings with the given values filled in; used by the setup wizard. */
|
|
25
|
+
export const createCompanySettings = (general?: Partial<CompanyGeneral>, branding?: Partial<CompanyBranding>): CompanySettings => ({
|
|
24
26
|
general: {
|
|
25
27
|
name: appConfig.name,
|
|
26
|
-
legalName: '
|
|
27
|
-
taxOffice: '
|
|
28
|
-
taxNumber: '
|
|
29
|
-
address: '
|
|
30
|
-
contactEmail: '
|
|
31
|
-
contactPhone: '+902161234567',
|
|
28
|
+
legalName: '',
|
|
29
|
+
taxOffice: '',
|
|
30
|
+
taxNumber: '',
|
|
31
|
+
address: '',
|
|
32
|
+
contactEmail: '',
|
|
32
33
|
defaultLanguage: 'en',
|
|
33
|
-
timeZone:
|
|
34
|
-
currency:
|
|
34
|
+
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
35
|
+
currency: appConfig.currency as Currency,
|
|
36
|
+
...general,
|
|
35
37
|
},
|
|
36
|
-
branding: { brand: appConfig.brand },
|
|
38
|
+
branding: { brand: appConfig.brand, ...branding },
|
|
37
39
|
security: {
|
|
38
40
|
requireTwoFactor: false,
|
|
39
41
|
password: { ...defaultPasswordPolicy, expiryDays: 0 },
|
|
@@ -42,7 +44,7 @@ const seed = (): CompanySettings => ({
|
|
|
42
44
|
})
|
|
43
45
|
|
|
44
46
|
function createMockCompanyService(): CompanyService {
|
|
45
|
-
const doc = createMockDocument('company',
|
|
47
|
+
const doc = createMockDocument('company', () => createCompanySettings(demoSeed<Partial<CompanyGeneral>>('companyGeneral')))
|
|
46
48
|
const assertOwnerOrAdmin = () => {
|
|
47
49
|
const role = authStore.getSession()?.user.role
|
|
48
50
|
if (role !== 'owner' && role !== 'admin') throw new ApiError(403, 'forbidden')
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { appConfig } from '@/app.config'
|
|
2
|
+
import { useCompanySettings } from './hooks'
|
|
3
|
+
|
|
4
|
+
/** The company name from setup and Settings → General; falls back to app.config.ts. */
|
|
5
|
+
export function useAppName() {
|
|
6
|
+
return useCompanySettings().data?.general.name || appConfig.name
|
|
7
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, type ReactNode } from 'react'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useAppName } from '@/core/company/use-app-name'
|
|
4
4
|
import { cn } from '@/lib/utils'
|
|
5
5
|
|
|
6
6
|
type PageProps = {
|
|
@@ -19,10 +19,11 @@ type PageProps = {
|
|
|
19
19
|
/** Standard page frame inside the content panel: title row + body. */
|
|
20
20
|
export function Page({ title, documentTitle, description, actions, leading, children, className }: PageProps) {
|
|
21
21
|
const docTitle = documentTitle ?? (typeof title === 'string' ? title : undefined)
|
|
22
|
+
const appName = useAppName()
|
|
22
23
|
|
|
23
24
|
useEffect(() => {
|
|
24
|
-
document.title = docTitle ? `${docTitle} · ${
|
|
25
|
-
}, [docTitle])
|
|
25
|
+
document.title = docTitle ? `${docTitle} · ${appName}` : appName
|
|
26
|
+
}, [docTitle, appName])
|
|
26
27
|
|
|
27
28
|
return (
|
|
28
29
|
<div className={cn('mx-auto flex w-full max-w-(--page-max-width) flex-col gap-6', className)}>
|
|
@@ -1,14 +1,16 @@
|
|
|
1
|
-
import { appConfig } from '@/app.config'
|
|
2
|
-
import type { AppConfig, FeatureId } from './types'
|
|
3
|
-
|
|
4
|
-
// Typed as AppConfig so a project's app.config.ts may omit newer optional keys.
|
|
5
|
-
const config: AppConfig = appConfig
|
|
6
|
-
|
|
7
|
-
/** Optional built-in areas are on unless app.config.ts switches them off. */
|
|
8
|
-
export const isFeatureOn = (feature: FeatureId) => config.features?.[feature] ?? true
|
|
9
|
-
|
|
10
|
-
export const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
1
|
+
import { appConfig } from '@/app.config'
|
|
2
|
+
import type { AppConfig, FeatureId } from './types'
|
|
3
|
+
|
|
4
|
+
// Typed as AppConfig so a project's app.config.ts may omit newer optional keys.
|
|
5
|
+
const config: AppConfig = appConfig
|
|
6
|
+
|
|
7
|
+
/** Optional built-in areas are on unless app.config.ts switches them off. */
|
|
8
|
+
export const isFeatureOn = (feature: FeatureId) => config.features?.[feature] ?? true
|
|
9
|
+
|
|
10
|
+
export const isDemo = config.demo ?? false
|
|
11
|
+
|
|
12
|
+
export const authMode = config.auth?.mode ?? 'mock'
|
|
13
|
+
export const csrfUrl = config.auth?.csrfUrl
|
|
14
|
+
|
|
15
|
+
/** `/` shows the landing page, or goes straight to the panel. */
|
|
16
|
+
export const showLanding = config.landing ?? true
|
|
@@ -2,7 +2,7 @@ import type { I18nKey } from '@/core/i18n/keys'
|
|
|
2
2
|
import type { BrandSetting } from '@/core/theme/brand'
|
|
3
3
|
import type { ModuleId } from '@/modules/registry'
|
|
4
4
|
|
|
5
|
-
export type Workspace = { id: string; name: string; plan
|
|
5
|
+
export type Workspace = { id: string; name: string; plan?: I18nKey }
|
|
6
6
|
|
|
7
7
|
export type NavGroupConfig = { id: string; label: I18nKey }
|
|
8
8
|
|
|
@@ -16,7 +16,6 @@ export type FeatureId =
|
|
|
16
16
|
| 'integrations'
|
|
17
17
|
| 'auditLog'
|
|
18
18
|
| 'dangerZone'
|
|
19
|
-
| 'demoDashboard'
|
|
20
19
|
|
|
21
20
|
/** mock: demo data; bearer: token in the Authorization header; cookie: session cookie + XSRF (Laravel Sanctum). */
|
|
22
21
|
export type AuthMode = 'mock' | 'bearer' | 'cookie'
|
|
@@ -29,6 +28,8 @@ export type AppConfig = {
|
|
|
29
28
|
modules: Partial<Record<ModuleId, boolean>>
|
|
30
29
|
currency: string
|
|
31
30
|
apiBaseUrl: string
|
|
31
|
+
/** The public demo: sample data, any password, no setup wizard. Real installs leave this off. */
|
|
32
|
+
demo?: boolean
|
|
32
33
|
/** Show the marketing landing page at `/`; when false, `/` opens the panel. Default true. */
|
|
33
34
|
landing?: boolean
|
|
34
35
|
features?: Partial<Record<FeatureId, boolean>>
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
useSidebar,
|
|
30
30
|
} from '@/components/ui/sidebar'
|
|
31
31
|
import { appConfig } from '@/app.config'
|
|
32
|
+
import { useAppName } from '@/core/company/use-app-name'
|
|
33
|
+
import type { Workspace } from '@/core/config/types'
|
|
32
34
|
import { useAuth } from '@/core/auth/use-auth'
|
|
33
35
|
import { useCompanySettings } from '@/core/company/hooks'
|
|
34
36
|
import { useTheme } from '@/core/theme/theme-context'
|
|
@@ -132,7 +134,8 @@ function LogoTile({ name, logo, className }: { name: string; logo?: string; clas
|
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
function WorkspaceSwitcher() {
|
|
135
|
-
const workspaces = appConfig.workspaces
|
|
137
|
+
const workspaces: Workspace[] = appConfig.workspaces
|
|
138
|
+
const appName = useAppName()
|
|
136
139
|
const [activeId, setActiveId] = useState(() => storage.get<string>('workspace') ?? workspaces[0]!.id)
|
|
137
140
|
const active = workspaces.find((w) => w.id === activeId) ?? workspaces[0]!
|
|
138
141
|
const t = useAnyT()
|
|
@@ -146,6 +149,20 @@ function WorkspaceSwitcher() {
|
|
|
146
149
|
setActiveId(id)
|
|
147
150
|
}
|
|
148
151
|
|
|
152
|
+
// One workspace: just the company name, nothing to switch.
|
|
153
|
+
if (workspaces.length === 1) {
|
|
154
|
+
return (
|
|
155
|
+
<SidebarMenu>
|
|
156
|
+
<SidebarMenuItem>
|
|
157
|
+
<div className="flex h-12 items-center gap-2 rounded-xl bg-raised p-2 shadow-(--shadow-raised) ring-1 ring-border group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:p-0 group-data-[collapsible=icon]:shadow-none group-data-[collapsible=icon]:ring-0">
|
|
158
|
+
<LogoTile name={appName} logo={logo} />
|
|
159
|
+
<span className="truncate text-sm font-semibold group-data-[collapsible=icon]:hidden">{appName}</span>
|
|
160
|
+
</div>
|
|
161
|
+
</SidebarMenuItem>
|
|
162
|
+
</SidebarMenu>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
149
166
|
return (
|
|
150
167
|
<SidebarMenu>
|
|
151
168
|
<SidebarMenuItem>
|
|
@@ -162,7 +179,7 @@ function WorkspaceSwitcher() {
|
|
|
162
179
|
<LogoTile name={active.name} logo={logo} />
|
|
163
180
|
<div className="grid min-w-0 flex-1 text-left leading-tight">
|
|
164
181
|
<span className="truncate text-sm font-semibold">{active.name}</span>
|
|
165
|
-
<span className="truncate text-2xs text-muted-foreground">{t(active.plan)}</span>
|
|
182
|
+
{active.plan && <span className="truncate text-2xs text-muted-foreground">{t(active.plan)}</span>}
|
|
166
183
|
</div>
|
|
167
184
|
<ChevronsUpDown className="ml-auto size-4 text-muted-foreground" />
|
|
168
185
|
</DropdownMenuTrigger>
|