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.
Files changed (79) hide show
  1. package/README.md +24 -23
  2. package/bin/create-freakpunch.mjs +6 -6
  3. package/files/README.md +19 -15
  4. package/files/app.config.ts +50 -58
  5. package/files/locales.ts +10 -0
  6. package/files/registry.ts +17 -0
  7. package/package.json +23 -23
  8. package/template/BACKEND.md +108 -86
  9. package/template/CLAUDE.md +15 -10
  10. package/template/design/COMPONENTS.md +1 -1
  11. package/template/design/DESIGN.md +3 -3
  12. package/template/design/PATTERNS.md +5 -5
  13. package/template/index.html +1 -1
  14. package/template/src/app.config.ts +50 -61
  15. package/template/src/app.roles.ts +24 -24
  16. package/template/src/core/api/demo.ts +8 -0
  17. package/template/src/core/audit/audit.service.ts +3 -34
  18. package/template/src/core/auth/auth.service.ts +25 -14
  19. package/template/src/core/auth/local-credentials.ts +46 -0
  20. package/template/src/core/auth/permissions.ts +1 -1
  21. package/template/src/core/company/company.service.ts +14 -12
  22. package/template/src/core/company/use-app-name.ts +7 -0
  23. package/template/src/core/components/page.tsx +4 -3
  24. package/template/src/core/config/runtime.ts +16 -14
  25. package/template/src/core/config/types.ts +3 -2
  26. package/template/src/core/layout/app-sidebar.tsx +19 -2
  27. package/template/src/core/notifications/notifications.service.ts +10 -115
  28. package/template/src/core/notifications/types.ts +5 -0
  29. package/template/src/core/setup/setup-gate.tsx +20 -0
  30. package/template/src/core/setup/setup.service.ts +101 -0
  31. package/template/src/core/users/users.service.ts +2 -2
  32. package/template/src/locales/en/auth.ts +2 -1
  33. package/template/src/locales/en/common.ts +1 -0
  34. package/template/src/locales/en/company.ts +3 -1
  35. package/template/src/locales/en/dashboard.ts +5 -0
  36. package/template/src/locales/en/setup.ts +45 -0
  37. package/template/src/locales/index.ts +4 -4
  38. package/template/src/locales/tr/auth.ts +2 -1
  39. package/template/src/locales/tr/common.ts +1 -0
  40. package/template/src/locales/tr/company.ts +3 -1
  41. package/template/src/locales/tr/dashboard.ts +5 -0
  42. package/template/src/locales/tr/setup.ts +49 -0
  43. package/template/src/modules/locales.ts +0 -4
  44. package/template/src/modules/registry.ts +17 -19
  45. package/template/src/pages/dashboard/dashboard-page.tsx +28 -243
  46. package/template/src/pages/dashboard/quick-actions.tsx +37 -0
  47. package/template/src/pages/login-page.tsx +26 -14
  48. package/template/src/pages/not-found-page.tsx +1 -2
  49. package/template/src/pages/settings/account/account.service.ts +22 -41
  50. package/template/src/pages/settings/account/notifications-page.tsx +3 -3
  51. package/template/src/pages/settings/company/general-page.tsx +6 -4
  52. package/template/src/pages/settings/company/integrations/integrations.service.ts +8 -28
  53. package/template/src/pages/settings/company/modules-page.tsx +10 -2
  54. package/template/src/pages/settings/company/roles-page.tsx +2 -1
  55. package/template/src/pages/setup-page.tsx +387 -0
  56. package/template/src/router.tsx +9 -4
  57. package/template/tools/new-module.mjs +90 -90
  58. package/template/tools/templates/module/index.ts.tpl +44 -37
  59. package/template/tools/templates/module/locales/en.ts.tpl +51 -45
  60. package/template/tools/templates/module/locales/tr.ts.tpl +55 -49
  61. package/template/{src/modules/products/pages/product-detail-page.tsx → tools/templates/module/pages/__id__-detail-page.tsx.tpl} +103 -108
  62. package/template/tools/templates/module/pages/__id__-form-page.tsx.tpl +121 -121
  63. package/template/tools/templates/module/pages/__id__-list-page.tsx.tpl +234 -237
  64. package/template/vite.config.ts +6 -3
  65. package/template/src/core/users/users.mock.ts +0 -24
  66. package/template/src/locales/en/landing.ts +0 -15
  67. package/template/src/locales/tr/landing.ts +0 -19
  68. package/template/src/modules/products/api/products.mock.ts +0 -46
  69. package/template/src/modules/products/api/products.service.ts +0 -84
  70. package/template/src/modules/products/components/product-status.tsx +0 -21
  71. package/template/src/modules/products/hooks.ts +0 -73
  72. package/template/src/modules/products/index.ts +0 -50
  73. package/template/src/modules/products/locales/en.ts +0 -78
  74. package/template/src/modules/products/locales/tr.ts +0 -82
  75. package/template/src/modules/products/pages/product-form-page.tsx +0 -211
  76. package/template/src/modules/products/pages/products-list-page.tsx +0 -286
  77. package/template/src/modules/products/types.ts +0 -61
  78. package/template/src/pages/dashboard/dashboard.service.ts +0 -92
  79. 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 the freakpunch admin template. Every screen in every project built
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
- │ ▣ Products ←pill│ description │
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 product", not "New Product"), in both languages.
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: `src/modules/products/pages/products-list-page.tsx`, `src/pages/settings/company/users-page.tsx`.
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="Products" description="…" actions={<primary create button>}
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: `src/modules/products/pages/product-form-page.tsx`.
27
+ Reference: the form page `npm run new-module` generates.
28
28
 
29
29
  ```
30
- Page title="New product" description="…"
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: `src/modules/products/pages/product-detail-page.tsx`.
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)}
@@ -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="freakpunch admin template" />
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
- * Per-project configuration. This is the only file a new project needs to
5
- * touch to rename the app, change the brand color and switch modules on/off.
6
- */
7
- export const appConfig = {
8
- name: 'freakpunch',
9
- /** A preset key from src/core/theme/brand.ts, or a custom { color, foreground } pair. */
10
- brand: 'indigo',
11
-
12
- /** Workspaces shown in the sidebar switcher (mock until a backend provides them). */
13
- workspaces: [
14
- { id: 'freakpunch', name: 'freakpunch', plan: 'common:plans.personal' },
15
- { id: 'acme', name: 'Acme Store', plan: 'common:plans.business' },
16
- { id: 'northwind', name: 'Northwind News', plan: 'common:plans.team' },
17
- ],
18
-
19
- /** Sidebar group order; labels are translation keys. Modules attach nav items to these ids. */
20
- navGroups: [
21
- { id: 'overview', label: 'common:nav.groups.overview' },
22
- { id: 'catalog', label: 'common:nav.groups.catalog' },
23
- { id: 'content', label: 'common:nav.groups.content' },
24
- { id: 'system', label: 'common:nav.groups.system' },
25
- ],
26
-
27
- /** Turn modules on/off. Keys must exist in src/modules/registry.ts. */
28
- modules: {
29
- products: true,
30
- // fp:modules
31
- },
32
-
33
- /** Marketing page at `/`; set false to open the panel directly. */
34
- landing: true,
35
-
36
- /** Built-in areas (all on by default). Switch off what a project doesn't need. */
37
- features: {
38
- notifications: true,
39
- users: true,
40
- roles: true,
41
- securityPolicies: true,
42
- modules: true,
43
- integrations: true,
44
- auditLog: true,
45
- dangerZone: true,
46
- /** The sample KPI/revenue/activity cards on the dashboard. */
47
- demoDashboard: true,
48
- },
49
-
50
- /** mock (demo data) | bearer (JWT) | cookie (Laravel Sanctum). See BACKEND.md. */
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: 'assets.assign', label: { en: 'Assign assets', tr: 'Zimmet ata' } }] as const
10
- * customRoles = {
11
- * hr: {
12
- * label: { en: 'HR manager', tr: 'İK yöneticisi' },
13
- * description: { en: 'Manages staff assets.', tr: 'Personel zimmetlerini yönetir.' },
14
- * permissions: ['dashboard.view', 'assets.view', 'assets.assign'],
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 { createMockCollection, delay, paginate, seededRandom } from '@/core/api/mock'
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', seed)
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
- const OWNER_EMAIL = 'owner@freakpunch.site'
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
- * Mock auth: any password works. A known email (owner@, admin@, editor@,
24
- * viewer@freakpunch.site or any team member) signs in as that user; an
25
- * unknown email signs in as the Owner demo account.
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
- const user = (await findUserByEmail(email)) ?? (await findUserByEmail(OWNER_EMAIL))
31
- if (!user) throw new ApiError(404, userErrors.inactive)
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 startSession(user)
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 startSession(user)
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
- if (authMode === 'cookie' && csrfUrl) await fetch(csrfUrl, { credentials: 'include' })
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. `assets.assign`. */
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
- const seed = (): CompanySettings => ({
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: 'Freakpunch Yazılım A.Ş.',
27
- taxOffice: 'Kadıköy',
28
- taxNumber: '1234567890',
29
- address: 'Caferağa Mah. Moda Cad. No: 12\n34710 Kadıköy / İstanbul',
30
- contactEmail: 'hello@freakpunch.site',
31
- contactPhone: '+902161234567',
28
+ legalName: '',
29
+ taxOffice: '',
30
+ taxNumber: '',
31
+ address: '',
32
+ contactEmail: '',
32
33
  defaultLanguage: 'en',
33
- timeZone: 'Europe/Istanbul',
34
- currency: 'USD',
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', seed)
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 { appConfig } from '@/app.config'
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} · ${appConfig.name}` : appConfig.name
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 authMode = config.auth?.mode ?? 'mock'
11
- export const csrfUrl = config.auth?.csrfUrl
12
-
13
- /** `/` shows the landing page, or goes straight to the panel. */
14
- export const showLanding = config.landing ?? true
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: I18nKey }
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>