@happyvertical/smrt-tenancy 0.30.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/AGENTS.md +71 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +122 -0
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/adapters/cli.d.ts +178 -0
- package/dist/adapters/cli.d.ts.map +1 -0
- package/dist/adapters/express.d.ts +115 -0
- package/dist/adapters/express.d.ts.map +1 -0
- package/dist/adapters/index.d.ts +22 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +7 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/sveltekit.d.ts +123 -0
- package/dist/adapters/sveltekit.d.ts.map +1 -0
- package/dist/chunks/context-B5CKsmMi.js +190 -0
- package/dist/chunks/context-B5CKsmMi.js.map +1 -0
- package/dist/chunks/sveltekit-9eRH1RLw.js +153 -0
- package/dist/chunks/sveltekit-9eRH1RLw.js.map +1 -0
- package/dist/chunks/testing-C_tV23JW.js +487 -0
- package/dist/chunks/testing-C_tV23JW.js.map +1 -0
- package/dist/context.d.ts +435 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/decorators.d.ts +126 -0
- package/dist/decorators.d.ts.map +1 -0
- package/dist/enabled-state.d.ts +25 -0
- package/dist/enabled-state.d.ts.map +1 -0
- package/dist/entry-point.d.ts +83 -0
- package/dist/entry-point.d.ts.map +1 -0
- package/dist/fields.d.ts +104 -0
- package/dist/fields.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +108 -0
- package/dist/index.js.map +1 -0
- package/dist/interceptor.d.ts +156 -0
- package/dist/interceptor.d.ts.map +1 -0
- package/dist/manifest.json +11 -0
- package/dist/playground.d.ts +2 -0
- package/dist/playground.d.ts.map +1 -0
- package/dist/playground.js +80 -0
- package/dist/playground.js.map +1 -0
- package/dist/registry.d.ts +145 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +65 -0
- package/dist/svelte/components/TenantCard.svelte +272 -0
- package/dist/svelte/components/TenantCard.svelte.d.ts +18 -0
- package/dist/svelte/components/TenantCard.svelte.d.ts.map +1 -0
- package/dist/svelte/components/TenantSwitcher.svelte +68 -0
- package/dist/svelte/components/TenantSwitcher.svelte.d.ts +11 -0
- package/dist/svelte/components/TenantSwitcher.svelte.d.ts.map +1 -0
- package/dist/svelte/i18n.d.ts +5 -0
- package/dist/svelte/i18n.d.ts.map +1 -0
- package/dist/svelte/i18n.js +9 -0
- package/dist/svelte/index.d.ts +15 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +19 -0
- package/dist/svelte/playground.d.ts +70 -0
- package/dist/svelte/playground.d.ts.map +1 -0
- package/dist/svelte/playground.js +75 -0
- package/dist/testing.d.ts +145 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +11 -0
- package/dist/testing.js.map +1 -0
- package/dist/ui.d.ts +21 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +33 -0
- package/dist/ui.js.map +1 -0
- package/package.json +99 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant-Scoped Class Registry
|
|
3
|
+
*
|
|
4
|
+
* Tracks which classes are tenant-scoped and their configuration.
|
|
5
|
+
* Used by the interceptor to determine how to handle operations.
|
|
6
|
+
*
|
|
7
|
+
* This registry supports two patterns:
|
|
8
|
+
* 1. @TenantScoped() decorator + tenantId field (original pattern)
|
|
9
|
+
* 2. @smrt({ tenantScoped: true }) in smrt-core (Issue #688 pattern)
|
|
10
|
+
*
|
|
11
|
+
* Both patterns are automatically recognized by the interceptor.
|
|
12
|
+
*
|
|
13
|
+
* @see https://github.com/happyvertical/smrt/issues/675
|
|
14
|
+
* @see https://github.com/happyvertical/smrt/issues/688
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Resolved tenancy configuration for a single class, as stored in the registry.
|
|
18
|
+
*
|
|
19
|
+
* Every field has a concrete (non-optional) value — defaults are applied by
|
|
20
|
+
* `registerTenantScopedClass()` when the class is registered via `@TenantScoped()`.
|
|
21
|
+
*
|
|
22
|
+
* @see TenantScopedOptions
|
|
23
|
+
* @see registerTenantScopedClass
|
|
24
|
+
*/
|
|
25
|
+
export interface TenantScopedConfig {
|
|
26
|
+
/**
|
|
27
|
+
* Tenancy mode for this class
|
|
28
|
+
* - 'required': Must have tenant context for all operations
|
|
29
|
+
* - 'optional': Works with or without tenant context
|
|
30
|
+
* @default 'required'
|
|
31
|
+
*/
|
|
32
|
+
mode: 'required' | 'optional';
|
|
33
|
+
/**
|
|
34
|
+
* Field name containing tenant ID
|
|
35
|
+
* @default 'tenantId'
|
|
36
|
+
*/
|
|
37
|
+
field: string;
|
|
38
|
+
/**
|
|
39
|
+
* Auto-filter all queries by tenant
|
|
40
|
+
* @default true
|
|
41
|
+
*/
|
|
42
|
+
autoFilter: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* Auto-populate tenant ID from context on create
|
|
45
|
+
* @default true
|
|
46
|
+
*/
|
|
47
|
+
autoPopulate: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Allow super admin bypass for this class
|
|
50
|
+
* @default false
|
|
51
|
+
*/
|
|
52
|
+
allowSuperAdminBypass: boolean;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Register a class as tenant-scoped with the given configuration.
|
|
56
|
+
*
|
|
57
|
+
* Called automatically by the `@TenantScoped()` decorator. You can also call
|
|
58
|
+
* this directly when you cannot use decorators (e.g., third-party classes or
|
|
59
|
+
* plain objects in tests). Defaults from `DEFAULT_CONFIG` are merged over any
|
|
60
|
+
* omitted options.
|
|
61
|
+
*
|
|
62
|
+
* Calling this again for the same `className` overwrites the previous entry.
|
|
63
|
+
*
|
|
64
|
+
* @param className - The class's `name` property (e.g., `'Document'`).
|
|
65
|
+
* @param config - Partial tenancy configuration; omitted fields receive defaults.
|
|
66
|
+
*
|
|
67
|
+
* @example
|
|
68
|
+
* ```typescript
|
|
69
|
+
* // Manually register a class (e.g., for testing)
|
|
70
|
+
* registerTenantScopedClass('Document', { mode: 'optional' });
|
|
71
|
+
* ```
|
|
72
|
+
*
|
|
73
|
+
* @see TenantScoped
|
|
74
|
+
* @see unregisterTenantScopedClass
|
|
75
|
+
*/
|
|
76
|
+
export declare function registerTenantScopedClass(className: string, config?: Partial<TenantScopedConfig>): void;
|
|
77
|
+
/**
|
|
78
|
+
* Remove a class from the tenant-scoped registry.
|
|
79
|
+
*
|
|
80
|
+
* Primarily intended for test teardown — use `clearTenantScopedRegistry()` to
|
|
81
|
+
* reset the entire registry at once.
|
|
82
|
+
*
|
|
83
|
+
* @param className - The class name to remove (e.g., `'Document'`).
|
|
84
|
+
*
|
|
85
|
+
* @see clearTenantScopedRegistry
|
|
86
|
+
* @see registerTenantScopedClass
|
|
87
|
+
*/
|
|
88
|
+
export declare function unregisterTenantScopedClass(className: string): void;
|
|
89
|
+
/**
|
|
90
|
+
* Return `true` if the named class is registered as tenant-scoped.
|
|
91
|
+
*
|
|
92
|
+
* Checks two sources in order:
|
|
93
|
+
* 1. The local registry populated by `@TenantScoped()`.
|
|
94
|
+
* 2. The core `ObjectRegistry` populated by `@smrt({ tenantScoped: true })`.
|
|
95
|
+
*
|
|
96
|
+
* @param className - The class name to look up (e.g., `'Document'`).
|
|
97
|
+
* @returns `true` if the class is tenant-scoped by either mechanism.
|
|
98
|
+
*
|
|
99
|
+
* @see getTenantScopedConfig
|
|
100
|
+
* @see registerTenantScopedClass
|
|
101
|
+
*/
|
|
102
|
+
export declare function isTenantScopedClass(className: string): boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Retrieve the resolved tenancy configuration for a class.
|
|
105
|
+
*
|
|
106
|
+
* Checks two sources in order, with the local registry taking precedence:
|
|
107
|
+
* 1. The local registry populated by `@TenantScoped()`.
|
|
108
|
+
* 2. The core `ObjectRegistry` populated by `@smrt({ tenantScoped: true })`.
|
|
109
|
+
*
|
|
110
|
+
* When found in the core registry, the raw config is normalised into a
|
|
111
|
+
* `TenantScopedConfig` with the same shape as locally registered classes.
|
|
112
|
+
*
|
|
113
|
+
* @param className - The class name to look up.
|
|
114
|
+
* @returns The `TenantScopedConfig` if the class is tenant-scoped, or
|
|
115
|
+
* `undefined` if it is not registered in either source.
|
|
116
|
+
*
|
|
117
|
+
* @see isTenantScopedClass
|
|
118
|
+
* @see getAllTenantScopedClasses
|
|
119
|
+
*/
|
|
120
|
+
export declare function getTenantScopedConfig(className: string): TenantScopedConfig | undefined;
|
|
121
|
+
/**
|
|
122
|
+
* Return a snapshot of all classes registered via `@TenantScoped()`.
|
|
123
|
+
*
|
|
124
|
+
* Returns a new `Map` so mutations to the returned value do not affect the
|
|
125
|
+
* internal registry. Note that classes registered only through the core
|
|
126
|
+
* `ObjectRegistry` (`@smrt({ tenantScoped: true })`) are **not** included in
|
|
127
|
+
* this map.
|
|
128
|
+
*
|
|
129
|
+
* @returns A copy of the local tenant-scoped class registry, keyed by class name.
|
|
130
|
+
*
|
|
131
|
+
* @see isTenantScopedClass
|
|
132
|
+
* @see getTenantScopedConfig
|
|
133
|
+
*/
|
|
134
|
+
export declare function getAllTenantScopedClasses(): Map<string, TenantScopedConfig>;
|
|
135
|
+
/**
|
|
136
|
+
* Remove all entries from the local tenant-scoped class registry.
|
|
137
|
+
*
|
|
138
|
+
* Intended for test teardown via `resetTenancy()`. Does not affect
|
|
139
|
+
* registrations held by the core `ObjectRegistry`.
|
|
140
|
+
*
|
|
141
|
+
* @see resetTenancy
|
|
142
|
+
* @see unregisterTenantScopedClass
|
|
143
|
+
*/
|
|
144
|
+
export declare function clearTenantScopedRegistry(): void;
|
|
145
|
+
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAIH;;;;;;;;GAQG;AACH,MAAM,WAAW,kBAAkB;IACjC;;;;;OAKG;IACH,IAAI,EAAE,UAAU,GAAG,UAAU,CAAC;IAE9B;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd;;;OAGG;IACH,UAAU,EAAE,OAAO,CAAC;IAEpB;;;OAGG;IACH,YAAY,EAAE,OAAO,CAAC;IAEtB;;;OAGG;IACH,qBAAqB,EAAE,OAAO,CAAC;CAChC;AAaD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,yBAAyB,CACvC,SAAS,EAAE,MAAM,EACjB,MAAM,GAAE,OAAO,CAAC,kBAAkB,CAAM,GACvC,IAAI,CAKN;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,2BAA2B,CAAC,SAAS,EAAE,MAAM,GAAG,IAAI,CAEnE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAO9D;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,MAAM,GAChB,kBAAkB,GAAG,SAAS,CAqBhC;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,IAAI,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAE3E;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,IAAI,IAAI,CAEhD"}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 1,
|
|
3
|
+
"generatedAt": "2026-06-23T01:11:11.521Z",
|
|
4
|
+
"packageName": "@happyvertical/smrt-tenancy",
|
|
5
|
+
"packageVersion": "0.30.0",
|
|
6
|
+
"sourceManifestPath": "dist/manifest.json",
|
|
7
|
+
"agentDocPath": "AGENTS.md",
|
|
8
|
+
"sourceHashes": {
|
|
9
|
+
"manifest": "34487a7449774d11873a68c8c58bfcde00949492655c695de325b5f110f3f83f",
|
|
10
|
+
"packageJson": "277085184246334ffbbe7841bffd1bbcfabe83d1159c3fa5d3faf1e66e77630f",
|
|
11
|
+
"agents": "6466580ac48829d3e51e940aaf42578919619e3a43fa7efbb741b744c80530c8"
|
|
12
|
+
},
|
|
13
|
+
"exports": [
|
|
14
|
+
".",
|
|
15
|
+
"./adapters",
|
|
16
|
+
"./manifest",
|
|
17
|
+
"./manifest.json",
|
|
18
|
+
"./playground",
|
|
19
|
+
"./svelte",
|
|
20
|
+
"./testing",
|
|
21
|
+
"./ui"
|
|
22
|
+
],
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@happyvertical/logger": "catalog:",
|
|
25
|
+
"@happyvertical/smrt-core": "workspace:*",
|
|
26
|
+
"@happyvertical/smrt-types": "workspace:*",
|
|
27
|
+
"@happyvertical/smrt-ui": "workspace:*",
|
|
28
|
+
"@happyvertical/sql": "catalog:",
|
|
29
|
+
"@happyvertical/utils": "catalog:",
|
|
30
|
+
"@happyvertical/smrt-vitest": "workspace:*",
|
|
31
|
+
"@sveltejs/package": "^2.5.7",
|
|
32
|
+
"@sveltejs/vite-plugin-svelte": "^6.2.4",
|
|
33
|
+
"@types/node": "25.0.9",
|
|
34
|
+
"svelte": "^5.46.4",
|
|
35
|
+
"svelte-check": "^4.3.5",
|
|
36
|
+
"typescript": "^5.9.3",
|
|
37
|
+
"vite": "^7.3.1",
|
|
38
|
+
"vitest": "^4.0.17"
|
|
39
|
+
},
|
|
40
|
+
"smrtDependencies": [
|
|
41
|
+
"@happyvertical/smrt-core",
|
|
42
|
+
"@happyvertical/smrt-types",
|
|
43
|
+
"@happyvertical/smrt-ui",
|
|
44
|
+
"@happyvertical/smrt-vitest"
|
|
45
|
+
],
|
|
46
|
+
"sdkDependencies": [
|
|
47
|
+
"@happyvertical/logger",
|
|
48
|
+
"@happyvertical/sql",
|
|
49
|
+
"@happyvertical/utils"
|
|
50
|
+
],
|
|
51
|
+
"tags": [],
|
|
52
|
+
"risks": [],
|
|
53
|
+
"objects": [],
|
|
54
|
+
"surfaces": [],
|
|
55
|
+
"prompts": [],
|
|
56
|
+
"relationshipsV2": {
|
|
57
|
+
"foreignKeyFields": 0,
|
|
58
|
+
"crossPackageRefFields": 0,
|
|
59
|
+
"junctionCollections": 0,
|
|
60
|
+
"hierarchicalObjects": 0,
|
|
61
|
+
"polymorphicAssociations": 0,
|
|
62
|
+
"uuidColumns": 0
|
|
63
|
+
},
|
|
64
|
+
"agentDoc": "# @happyvertical/smrt-tenancy\n\nMulti-tenancy via AsyncLocalStorage context propagation with automatic query filtering and tenant ID population.\n\n## Context Propagation\n\n```typescript\nimport { withTenant, getTenantId, withSystemContext } from '@happyvertical/smrt-tenancy';\n\nawait withTenant({ tenantId: 'tenant-123' }, async () => {\n // All SmrtCollection queries auto-filtered by tenantId\n // All creates auto-populate tenantId\n const docs = await collection.list({}); // WHERE tenant_id = 'tenant-123'\n});\n\nawait withSystemContext(async () => { /* bypasses all tenant checks */ });\n```\n\n**Critical distinction**: `withSystemContext()` sets a SYSTEM_CONTEXT_MARKER sentinel — different from \"no context\" (undefined). Interceptor can distinguish intentional bypass from missing context.\n\n## Interceptor System\n\nHooks into SmrtCollection via `GlobalInterceptors.register()` (priority 100, runs first):\n\n| Hook | Behavior |\n|------|----------|\n| `beforeList` | Injects `tenantId` into WHERE clause; validates existing filters match context |\n| `beforeGet` | Same — converts ID lookup to `{ id, tenantId }` |\n| `beforeSave` | Auto-populates tenantId if empty + `autoPopulate: true`; validates if already set |\n| `beforeDelete` | Validates instance.tenantId matches context |\n| `beforeQuery` | Enforces raw SQL policy on tenant-scoped classes (`throw`/`warn`/`allow`) |\n| `afterSave` | Emits `directory.<class>.created`/`updated` via `dispatchBus` for configured `directoryClasses` |\n| `afterDelete` | Emits `directory.<class>.deleted` via `dispatchBus` for configured `directoryClasses` |\n\nMismatches throw `TenantIsolationError`. Missing required context throws `TenantContextError`.\n\n## Registration — Two Patterns\n\n```typescript\n// Pattern 1: Tenancy decorator\n@TenantScoped({ mode: 'optional' })\nclass Doc extends SmrtObject { @tenantId({ nullable: true }) tenantId: string | null = null; }\n\n// Pattern 2: Core decorator (tenancy package reads this too)\n@smrt({ tenantScoped: { mode: 'optional' } })\nclass Doc extends SmrtObject { tenantId: string | null = null; }\n```\n\nModes: `'required'` (default — throws without context) or `'optional'` (passes through if no context).\n\n## Adapters\n\n- **Express**: `createExpressMiddleware()` — uses `enterTenantContext()` (not withTenant, because middleware returns before handlers run)\n- **SvelteKit**: `createSvelteKitHandle()` — stores context in `event.locals`\n- **CLI**: `createCliContext()` — `run()`, `runWithTenant()`, `runAsSystem()`, `runAsSuperAdmin()`\n\n## Super Admin Bypass\n\n`withSuperAdminBypass()` keeps tenant context but disables auto-filtering. Different from `withSystemContext()` which removes context entirely.\n\n## Gotchas\n\n- **Context lost in callbacks**: `setTimeout(() => getTenantId(), 100)` → undefined. Fix: `TenantContext.bind(fn)`\n- **Nested contexts override**: inner `withTenant()` overrides outer; restores on exit\n- **Auto-populate only if empty**: if tenantId already set, interceptor validates (not overwrites)\n- **Isolation checked at query time**: `list({ where: { tenantId: 'other' } })` throws immediately\n- **Testing**: `resetTenancy()` + `setupTestTenancy()` in beforeEach; `testTenantIsolation()` helper\n\n## Known exceptions to monorepo standards\n\n- **`serializeInstance()` in `src/interceptor.ts` calls `instance.toJSON()` directly** (standards.md §7 forbids this in favor of `transformJSON()`). The interceptor must serialize arbitrary instances handed to it — including workspace stubs and plain-object test doubles whose classes may not extend `SmrtObject` and therefore have no `transformJSON()` hook. The call is duck-typed and falls back to manual key iteration when `toJSON` is absent. See the inline comment at the call site for the full rationale.\n"
|
|
65
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* TenantCard - Tenant information and management
|
|
4
|
+
* refactored for Material 3
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Tenant } from '@happyvertical/smrt-types';
|
|
8
|
+
import { Icon, ripple } from '@happyvertical/smrt-ui';
|
|
9
|
+
import { useI18n } from '@happyvertical/smrt-ui/i18n';
|
|
10
|
+
import { M } from '../i18n.js';
|
|
11
|
+
|
|
12
|
+
export interface Props {
|
|
13
|
+
tenant: Tenant;
|
|
14
|
+
memberCount?: number;
|
|
15
|
+
onclick?: () => void;
|
|
16
|
+
selected?: boolean;
|
|
17
|
+
actions?: boolean;
|
|
18
|
+
onedit?: () => void;
|
|
19
|
+
ondelete?: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
tenant,
|
|
24
|
+
memberCount,
|
|
25
|
+
onclick,
|
|
26
|
+
selected = false,
|
|
27
|
+
actions = false,
|
|
28
|
+
onedit,
|
|
29
|
+
ondelete,
|
|
30
|
+
}: Props = $props();
|
|
31
|
+
|
|
32
|
+
const { t } = useI18n();
|
|
33
|
+
|
|
34
|
+
const statusClass = $derived.by(() => {
|
|
35
|
+
switch (tenant.status) {
|
|
36
|
+
case 'active':
|
|
37
|
+
return 'status-active';
|
|
38
|
+
case 'suspended':
|
|
39
|
+
return 'status-error';
|
|
40
|
+
case 'archived':
|
|
41
|
+
return 'status-disabled';
|
|
42
|
+
default:
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function getInitials(name: string): string {
|
|
48
|
+
return name
|
|
49
|
+
.split(' ')
|
|
50
|
+
.map((part) => part[0])
|
|
51
|
+
.join('')
|
|
52
|
+
.toUpperCase()
|
|
53
|
+
.slice(0, 2);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function getColor(name: string): string {
|
|
57
|
+
const colors = [
|
|
58
|
+
'#3b82f6',
|
|
59
|
+
'#8b5cf6',
|
|
60
|
+
'#ec4899',
|
|
61
|
+
'#f97316',
|
|
62
|
+
'#22c55e',
|
|
63
|
+
'#06b6d4',
|
|
64
|
+
'#6366f1',
|
|
65
|
+
'#14b8a6',
|
|
66
|
+
];
|
|
67
|
+
let hash = 0;
|
|
68
|
+
for (let i = 0; i < name.length; i++) {
|
|
69
|
+
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
70
|
+
}
|
|
71
|
+
return colors[Math.abs(hash) % colors.length];
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
76
|
+
<div
|
|
77
|
+
class="tenant-card"
|
|
78
|
+
class:clickable={!!onclick}
|
|
79
|
+
class:selected
|
|
80
|
+
role={onclick ? 'button' : undefined}
|
|
81
|
+
tabindex={onclick ? 0 : undefined}
|
|
82
|
+
onclick={onclick}
|
|
83
|
+
onkeydown={(event: KeyboardEvent) => event.key === 'Enter' && onclick?.()}
|
|
84
|
+
use:ripple
|
|
85
|
+
>
|
|
86
|
+
<div class="avatar" style:background-color={getColor(tenant.name ?? '')}>
|
|
87
|
+
{getInitials(tenant.name ?? 'T')}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div class="info">
|
|
91
|
+
<div class="header">
|
|
92
|
+
<span class="name">{tenant.name}</span>
|
|
93
|
+
<span class="status {statusClass}">{tenant.status}</span>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
{#if tenant.slug}
|
|
97
|
+
<div class="slug">{tenant.slug}</div>
|
|
98
|
+
{/if}
|
|
99
|
+
|
|
100
|
+
{#if memberCount !== undefined}
|
|
101
|
+
<div class="meta">
|
|
102
|
+
<span class="members">{memberCount} member{memberCount === 1 ? '' : 's'}</span>
|
|
103
|
+
</div>
|
|
104
|
+
{/if}
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
{#if actions && (onedit || ondelete)}
|
|
108
|
+
<div class="actions">
|
|
109
|
+
{#if onedit}
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
class="action-btn"
|
|
113
|
+
onclick={(event: MouseEvent) => { event.stopPropagation(); onedit?.(); }}
|
|
114
|
+
use:ripple
|
|
115
|
+
aria-label={t(M['tenancy.tenant_card.edit'])}
|
|
116
|
+
>
|
|
117
|
+
<Icon name="menu" size={18} />
|
|
118
|
+
</button>
|
|
119
|
+
{/if}
|
|
120
|
+
{#if ondelete}
|
|
121
|
+
<button
|
|
122
|
+
type="button"
|
|
123
|
+
class="action-btn danger"
|
|
124
|
+
onclick={(event: MouseEvent) => { event.stopPropagation(); ondelete?.(); }}
|
|
125
|
+
use:ripple
|
|
126
|
+
aria-label={t(M['tenancy.tenant_card.delete'])}
|
|
127
|
+
>
|
|
128
|
+
<Icon name="close" size={18} />
|
|
129
|
+
</button>
|
|
130
|
+
{/if}
|
|
131
|
+
</div>
|
|
132
|
+
{/if}
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
<style>
|
|
136
|
+
.tenant-card {
|
|
137
|
+
display: flex;
|
|
138
|
+
align-items: center;
|
|
139
|
+
gap: var(--smrt-spacing-4, 16px);
|
|
140
|
+
padding: var(--smrt-spacing-4, 16px);
|
|
141
|
+
background-color: var(--smrt-color-surface-container-low);
|
|
142
|
+
border-radius: var(--smrt-radius-lg, 12px);
|
|
143
|
+
color: var(--smrt-color-on-surface);
|
|
144
|
+
transition: all 200ms cubic-bezier(0.2, 0, 0, 1);
|
|
145
|
+
position: relative;
|
|
146
|
+
overflow: hidden;
|
|
147
|
+
box-shadow: var(--smrt-elevation-1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.tenant-card.clickable {
|
|
151
|
+
cursor: pointer;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.tenant-card.clickable:hover {
|
|
155
|
+
background-color: var(--smrt-color-surface-container-high);
|
|
156
|
+
box-shadow: var(--smrt-elevation-2);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.tenant-card.selected {
|
|
160
|
+
background-color: var(--smrt-color-secondary-container);
|
|
161
|
+
color: var(--smrt-color-on-secondary-container);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.avatar {
|
|
165
|
+
display: flex;
|
|
166
|
+
align-items: center;
|
|
167
|
+
justify-content: center;
|
|
168
|
+
width: 48px;
|
|
169
|
+
height: 48px;
|
|
170
|
+
border-radius: var(--smrt-radius-lg, 12px);
|
|
171
|
+
color: white;
|
|
172
|
+
font: var(--smrt-typography-title-medium-font);
|
|
173
|
+
font-weight: var(--smrt-typography-weight-semibold, 600);
|
|
174
|
+
flex-shrink: 0;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.info {
|
|
178
|
+
flex: 1;
|
|
179
|
+
min-width: 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.header {
|
|
183
|
+
display: flex;
|
|
184
|
+
align-items: center;
|
|
185
|
+
gap: var(--smrt-spacing-2, 8px);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.name {
|
|
189
|
+
font: var(--smrt-typography-title-small-font);
|
|
190
|
+
font-weight: var(--smrt-typography-weight-semibold, 600);
|
|
191
|
+
white-space: nowrap;
|
|
192
|
+
overflow: hidden;
|
|
193
|
+
text-overflow: ellipsis;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.status {
|
|
197
|
+
font: var(--smrt-typography-label-small-font);
|
|
198
|
+
padding: 0 var(--smrt-spacing-2, 8px);
|
|
199
|
+
height: 18px;
|
|
200
|
+
display: inline-flex;
|
|
201
|
+
align-items: center;
|
|
202
|
+
border-radius: var(--smrt-radius-md, 8px);
|
|
203
|
+
text-transform: uppercase;
|
|
204
|
+
font-weight: var(--smrt-typography-weight-semibold, 600);
|
|
205
|
+
flex-shrink: 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.status-active {
|
|
209
|
+
background-color: var(--smrt-color-primary-container);
|
|
210
|
+
color: var(--smrt-color-on-primary-container);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.status-error {
|
|
214
|
+
background-color: var(--smrt-color-error-container);
|
|
215
|
+
color: var(--smrt-color-on-error-container);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.status-disabled {
|
|
219
|
+
background-color: var(--smrt-color-surface-variant);
|
|
220
|
+
color: var(--smrt-color-on-surface-variant);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.slug {
|
|
224
|
+
font: var(--smrt-typography-body-small-font);
|
|
225
|
+
color: var(--smrt-color-on-surface-variant);
|
|
226
|
+
margin-top: var(--smrt-spacing-1, 4px);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
.selected .slug {
|
|
230
|
+
color: var(--smrt-color-on-secondary-container);
|
|
231
|
+
opacity: 0.8;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.meta {
|
|
235
|
+
margin-top: var(--smrt-spacing-1, 4px);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.members {
|
|
239
|
+
font: var(--smrt-typography-label-small-font);
|
|
240
|
+
color: var(--smrt-color-on-surface-variant);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.actions {
|
|
244
|
+
display: flex;
|
|
245
|
+
gap: var(--smrt-spacing-1, 4px);
|
|
246
|
+
flex-shrink: 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.action-btn {
|
|
250
|
+
display: flex;
|
|
251
|
+
align-items: center;
|
|
252
|
+
justify-content: center;
|
|
253
|
+
width: 32px;
|
|
254
|
+
height: 32px;
|
|
255
|
+
background: transparent;
|
|
256
|
+
border: none;
|
|
257
|
+
border-radius: var(--smrt-radius-full, 9999px);
|
|
258
|
+
cursor: pointer;
|
|
259
|
+
color: var(--smrt-color-on-surface-variant);
|
|
260
|
+
position: relative;
|
|
261
|
+
overflow: hidden;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
.action-btn:hover {
|
|
265
|
+
background-color: var(--smrt-color-surface-container-highest);
|
|
266
|
+
color: var(--smrt-color-on-surface);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.action-btn.danger:hover {
|
|
270
|
+
color: var(--smrt-color-error);
|
|
271
|
+
}
|
|
272
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TenantCard - Tenant information and management
|
|
3
|
+
* refactored for Material 3
|
|
4
|
+
*/
|
|
5
|
+
import type { Tenant } from '@happyvertical/smrt-types';
|
|
6
|
+
export interface Props {
|
|
7
|
+
tenant: Tenant;
|
|
8
|
+
memberCount?: number;
|
|
9
|
+
onclick?: () => void;
|
|
10
|
+
selected?: boolean;
|
|
11
|
+
actions?: boolean;
|
|
12
|
+
onedit?: () => void;
|
|
13
|
+
ondelete?: () => void;
|
|
14
|
+
}
|
|
15
|
+
declare const TenantCard: import("svelte").Component<Props, {}, "">;
|
|
16
|
+
type TenantCard = ReturnType<typeof TenantCard>;
|
|
17
|
+
export default TenantCard;
|
|
18
|
+
//# sourceMappingURL=TenantCard.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TenantCard.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/TenantCard.svelte.ts"],"names":[],"mappings":"AAGA;;;GAGG;AACH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,2BAA2B,CAAC;AAMxD,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;CACvB;AA0GD,QAAA,MAAM,UAAU,2CAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Membership, Tenant } from '@happyvertical/smrt-types';
|
|
3
|
+
|
|
4
|
+
export interface Props {
|
|
5
|
+
memberships: Membership[];
|
|
6
|
+
tenants: Map<string, Tenant>;
|
|
7
|
+
currentTenantId: string;
|
|
8
|
+
onchange?: (tenantId: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { memberships, tenants, currentTenantId, onchange }: Props = $props();
|
|
12
|
+
|
|
13
|
+
const currentTenant = $derived(tenants.get(currentTenantId));
|
|
14
|
+
|
|
15
|
+
function handleChange(event: Event) {
|
|
16
|
+
const select = event.target as HTMLSelectElement;
|
|
17
|
+
onchange?.(select.value);
|
|
18
|
+
}
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
<div class="tenant-switcher">
|
|
22
|
+
{#if memberships.length <= 1}
|
|
23
|
+
<span class="tenant-name">{currentTenant?.name ?? 'Unknown'}</span>
|
|
24
|
+
{:else}
|
|
25
|
+
<select value={currentTenantId} onchange={handleChange} class="tenant-select">
|
|
26
|
+
{#each memberships as membership}
|
|
27
|
+
{#if membership.tenantId}
|
|
28
|
+
{@const tenant = tenants.get(membership.tenantId)}
|
|
29
|
+
<option value={membership.tenantId}>
|
|
30
|
+
{tenant?.name ?? 'Unknown'}
|
|
31
|
+
</option>
|
|
32
|
+
{/if}
|
|
33
|
+
{/each}
|
|
34
|
+
</select>
|
|
35
|
+
{/if}
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<style>
|
|
39
|
+
.tenant-switcher {
|
|
40
|
+
display: inline-flex;
|
|
41
|
+
align-items: center;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.tenant-name {
|
|
45
|
+
font-weight: var(--smrt-typography-weight-medium, 500);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.tenant-select {
|
|
49
|
+
padding: var(--smrt-spacing-sm, 0.5rem) var(--smrt-spacing-md, 1rem);
|
|
50
|
+
border: 1px solid var(--smrt-color-outline-variant, #c4c6cf);
|
|
51
|
+
border-radius: var(--smrt-radius-medium, 0.5rem);
|
|
52
|
+
background: var(--smrt-color-surface, white);
|
|
53
|
+
font: var(--smrt-typography-body-medium-font, 0.875rem / 1.25 sans-serif);
|
|
54
|
+
cursor: pointer;
|
|
55
|
+
transition: border-color var(--smrt-duration-short2, 150ms) var(--smrt-easing-standard, ease);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
.tenant-select:focus {
|
|
59
|
+
outline: 2px solid var(--smrt-color-primary, #005ac1);
|
|
60
|
+
outline-offset: 2px;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@media (prefers-reduced-motion: reduce) {
|
|
64
|
+
.tenant-select {
|
|
65
|
+
transition: none;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Membership, Tenant } from '@happyvertical/smrt-types';
|
|
2
|
+
export interface Props {
|
|
3
|
+
memberships: Membership[];
|
|
4
|
+
tenants: Map<string, Tenant>;
|
|
5
|
+
currentTenantId: string;
|
|
6
|
+
onchange?: (tenantId: string) => void;
|
|
7
|
+
}
|
|
8
|
+
declare const TenantSwitcher: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type TenantSwitcher = ReturnType<typeof TenantSwitcher>;
|
|
10
|
+
export default TenantSwitcher;
|
|
11
|
+
//# sourceMappingURL=TenantSwitcher.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TenantSwitcher.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/TenantSwitcher.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,2BAA2B,CAAC;AAGpE,MAAM,WAAW,KAAK;IACpB,WAAW,EAAE,UAAU,EAAE,CAAC;IAC1B,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC7B,eAAe,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CACvC;AAoCD,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../../src/svelte/i18n.ts"],"names":[],"mappings":"AAMA,eAAO,MAAM,CAAC;;;CAGZ,CAAC"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* smrt-tenancy UI message catalog (S13 #1418).
|
|
3
|
+
* Keys: `tenancy.<component>.<descriptor>`.
|
|
4
|
+
*/
|
|
5
|
+
import { defineMessages } from '@happyvertical/smrt-ui/i18n';
|
|
6
|
+
export const M = defineMessages({
|
|
7
|
+
'tenancy.tenant_card.edit': 'Edit',
|
|
8
|
+
'tenancy.tenant_card.delete': 'Delete',
|
|
9
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenancy Module Svelte Components
|
|
3
|
+
*
|
|
4
|
+
* Optional Svelte UI components for tenant management.
|
|
5
|
+
* Auto-registers components with ModuleUIRegistry on import.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
import type { ComponentProps } from 'svelte';
|
|
10
|
+
import TenantCard from './components/TenantCard.svelte';
|
|
11
|
+
import TenantSwitcher from './components/TenantSwitcher.svelte';
|
|
12
|
+
export { TenantCard, TenantSwitcher };
|
|
13
|
+
export type TenantCardProps = ComponentProps<typeof TenantCard>;
|
|
14
|
+
export type TenantSwitcherProps = ComponentProps<typeof TenantSwitcher>;
|
|
15
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/svelte/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,QAAQ,CAAC;AAI7C,OAAO,UAAU,MAAM,gCAAgC,CAAC;AACxD,OAAO,cAAc,MAAM,oCAAoC,CAAC;AAGhE,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,CAAC;AAGtC,MAAM,MAAM,eAAe,GAAG,cAAc,CAAC,OAAO,UAAU,CAAC,CAAC;AAChE,MAAM,MAAM,mBAAmB,GAAG,cAAc,CAAC,OAAO,cAAc,CAAC,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenancy Module Svelte Components
|
|
3
|
+
*
|
|
4
|
+
* Optional Svelte UI components for tenant management.
|
|
5
|
+
* Auto-registers components with ModuleUIRegistry on import.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
import { ModuleUIRegistry } from '@happyvertical/smrt-ui/registry';
|
|
10
|
+
import { TENANCY_MODULE_META } from '../ui.js';
|
|
11
|
+
// Import components
|
|
12
|
+
import TenantCard from './components/TenantCard.svelte';
|
|
13
|
+
import TenantSwitcher from './components/TenantSwitcher.svelte';
|
|
14
|
+
// Export components
|
|
15
|
+
export { TenantCard, TenantSwitcher };
|
|
16
|
+
// Auto-register with ModuleUIRegistry
|
|
17
|
+
ModuleUIRegistry.registerModule(TENANCY_MODULE_META);
|
|
18
|
+
ModuleUIRegistry.register('@happyvertical/smrt-tenancy', 'tenant-card', TenantCard);
|
|
19
|
+
ModuleUIRegistry.register('@happyvertical/smrt-tenancy', 'tenant-switcher', TenantSwitcher);
|