@nuxt-customer-portal/core 0.3.2 → 0.4.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 (45) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +4 -0
  3. package/app/components/PortalTimezoneSelect.vue +28 -0
  4. package/app/composables/useClientConfiguration.ts +8 -0
  5. package/app/composables/usePaginatedResource.ts +2 -1
  6. package/app/composables/usePortalSession.ts +2 -0
  7. package/app/composables/useUserStore.ts +5 -0
  8. package/app/utils/auth-client.ts +4 -1
  9. package/i18n/locales/en.json +22 -0
  10. package/i18n/locales/nl.json +22 -0
  11. package/migrations/0006_timezone_preferences.sql +22 -0
  12. package/migrations/0007_user_names.sql +2 -0
  13. package/migrations/0008_better_auth_api_keys.sql +27 -0
  14. package/nuxt.config.ts +16 -5
  15. package/package.json +8 -3
  16. package/portal.manifest.mjs +1 -1
  17. package/server/api/auth/permissions.get.ts +4 -1
  18. package/server/api/timezones.get.ts +9 -0
  19. package/server/db/schema/auth-schema.ts +46 -1
  20. package/server/middleware/auth.ts +10 -0
  21. package/server/utils/auth.ts +150 -12
  22. package/server/utils/business-hooks.ts +34 -0
  23. package/server/utils/client-account-policy.ts +15 -0
  24. package/server/utils/client-configuration.ts +9 -0
  25. package/server/utils/email-texts.ts +27 -0
  26. package/server/utils/email.ts +25 -2
  27. package/server/utils/invitation-management.ts +4 -0
  28. package/server/utils/openapi-contracts.ts +8 -0
  29. package/server/utils/permissions.ts +4 -0
  30. package/server/utils/portal-email.ts +12 -4
  31. package/server/utils/public-api-route.ts +14 -0
  32. package/server/utils/public-invitation-route.ts +3 -0
  33. package/server/utils/timezone-validation.ts +4 -0
  34. package/server/utils/timezones.ts +28 -0
  35. package/shared/appointment-time.ts +43 -0
  36. package/shared/core-feature.ts +82 -29
  37. package/shared/countries.ts +18 -0
  38. package/shared/email-recipient.ts +7 -0
  39. package/shared/languages.ts +7 -0
  40. package/shared/permissions.ts +25 -0
  41. package/shared/portal-session.ts +2 -0
  42. package/shared/social-auth.ts +5 -0
  43. package/shared/timezone.ts +21 -0
  44. package/shared/types/feature.ts +11 -0
  45. package/shared/types/sections/database.ts +3 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # @nuxt-customer-portal/core
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 59c8921: Support organization and personal clients with optional verified self-registration, single-person access safeguards, and shared provider, client, and user timezone preferences. Preserve existing B2B defaults, module references, invoice history, and Timesheets timezone behavior.
8
+
9
+ Collect and store first and last names during signup and personal onboarding. Route ordinary personal registration and verified users without memberships or pending invitations through onboarding.
10
+
11
+ Allow users to edit first name, last name, and an independent display name in profile settings.
12
+
13
+ Synchronize display-name edits with the linked private client name and current billing name in the same transaction, preserving organizations and historical invoices.
14
+
15
+ Let private clients edit their own billing address in profile settings, independently of the active organization, with server-side ownership checks.
16
+
17
+ Private clients use their own invoice email without contact persons; hide contact management and reject private-client contact creation or selection server-side.
18
+
19
+ Add a B2C-only Invite private client action to the Users page, supporting new and existing private clients with atomic record creation, request retry protection, and pending invitation recovery when email delivery fails.
20
+
21
+ - 59c8921: Replace the Products-specific API key implementation with organization-owned Better Auth API keys. Restrict key ownership and use to the provider organization, restrict administration to system administrators, and let modules declare the API scopes administrators can assign.
22
+
23
+ Move API-key management from Products store settings to a dedicated system Administration page.
24
+
25
+ - 59c8921: Allow SaaS administrators to configure allowed client types and personal self-registration in Portal Settings. Apply saved preferences to server authorization, onboarding, client forms and navigation, preserving configuration defaults for existing portals. Prevent disabling client types that still have records, including during concurrent creation.
26
+ - 59c8921: Configure supported store content and SaaS interface languages using the shared bundled language list. Preserve disabled translations and switch unavailable UI locales to an enabled language.
27
+
28
+ ### Patch Changes
29
+
30
+ - 59c8921: Share appointment time formatting across Planning and checkout, respecting browser locale preferences and displaying midnight concisely.
31
+
32
+ Present checkout booking details with distinct date, time, and provider rows and a stable live reservation countdown.
33
+
34
+ - 59c8921: Restore anonymous booking availability and session-bound hold operations through the authentication gate. Preserve endpoint rate limits, origin checks, and reservation credentials.
35
+ - 59c8921: Add the Products store module and product invoice integration: translated digital products and services, secret catalog API keys, Stripe one-time checkout, guest invitations, purchaser-only delivery, exact multi-currency invoices, and refund credit notes. Add narrowly scoped external authentication routes, API documentation contracts, and configurable module activation. Existing stores remain closed until explicitly configured and enabled.
36
+ - 59c8921: Store the sender country as structured invoice data, show it in invoice settings, and preserve it on generated invoices.
37
+
38
+ ## 0.3.3
39
+
3
40
  ## 0.3.2
4
41
 
5
42
  ### Patch Changes
package/README.md CHANGED
@@ -30,6 +30,10 @@ const products = usePaginatedResource<Product, ProductFilters>({
30
30
  })
31
31
  ```
32
32
 
33
+ A failed page load never rejects: it resolves with no result and exposes the
34
+ cause on `error`, so a list awaited at the top level of `setup()` still renders.
35
+ Callers check the returned result before using it and render `error` themselves.
36
+
33
37
  Use `useAutoPagination` with a list container and trailing sentinel when the next
34
38
  page should load automatically. Conventional pagination controls can use the
35
39
  same `pagination` state alongside it.
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ const model = defineModel<string | null>({ default: null })
3
+ const props = withDefaults(defineProps<{ inheritedTimezone?: string; allowInherit?: boolean }>(), {
4
+ allowInherit: true
5
+ })
6
+ const { t } = useI18n()
7
+ const value = computed({
8
+ get: () => model.value ?? '__inherit__',
9
+ set: (value: string) => {
10
+ model.value = value === '__inherit__' ? null : value
11
+ }
12
+ })
13
+ const options = computed(() => [
14
+ ...(props.allowInherit !== false
15
+ ? [
16
+ {
17
+ label: t('timezones.inherit', { timezone: props.inheritedTimezone || 'Europe/Amsterdam' }),
18
+ value: '__inherit__'
19
+ }
20
+ ]
21
+ : []),
22
+ ...[...new Set(['UTC', ...Intl.supportedValuesOf('timeZone'), ...(model.value ? [model.value] : [])])]
23
+ .sort()
24
+ .map((zone) => ({ label: zone, value: zone }))
25
+ ])
26
+ </script>
27
+
28
+ <template><USelectMenu v-model="value" :items="options" value-key="value" class="w-full" /></template>
@@ -0,0 +1,8 @@
1
+ export const useClientConfiguration = () => {
2
+ const runtime = useRuntimeConfig()
3
+ const settings = useState<{ clients?: { allowedTypes: string[]; personalSelfRegistration: boolean } } | null>(
4
+ 'portal-runtime-settings',
5
+ () => null
6
+ )
7
+ return computed(() => ({ ...runtime.public.clients, ...settings.value?.clients }))
8
+ }
@@ -95,8 +95,9 @@ export const usePaginatedResource = <Item, Filters extends object>(
95
95
  if (sequence !== requestSequence || (cause instanceof Error && cause.name === 'AbortError')) {
96
96
  return
97
97
  }
98
+ // Resolve with no result rather than rejecting: callers await this at the top level of
99
+ // `setup()`, where a rejection aborts the whole route instead of rendering `error`.
98
100
  error.value = cause instanceof Error ? cause : new Error('Failed to load data')
99
- throw cause
100
101
  } finally {
101
102
  if (sequence === requestSequence) {
102
103
  pending.value = false
@@ -4,6 +4,7 @@ export const usePortalSession = () => {
4
4
  activeOrganizationId,
5
5
  activeOrganizationRole,
6
6
  activeOrganizationType,
7
+ activeOrganizationIsPersonal,
7
8
  currentUser,
8
9
  isAdmin,
9
10
  isAuthenticated
@@ -13,6 +14,7 @@ export const usePortalSession = () => {
13
14
  activeOrganizationId: readonly(activeOrganizationId),
14
15
  activeOrganizationRole: readonly(activeOrganizationRole),
15
16
  activeOrganizationType: readonly(activeOrganizationType),
17
+ activeOrganizationIsPersonal: readonly(activeOrganizationIsPersonal),
16
18
  currentUser: readonly(currentUser),
17
19
  isSystemAdmin: readonly(isAdmin),
18
20
  isAuthenticated: readonly(isAuthenticated)
@@ -14,6 +14,7 @@ interface PermissionsResponse {
14
14
  organizationRole: string | null
15
15
  activeOrganization: Organization | null
16
16
  organizationType: 'PROVIDER' | 'CLIENT' | null
17
+ isPersonalClient: boolean
17
18
  }
18
19
 
19
20
  export const useUserStore = defineStore('user', () => {
@@ -95,6 +96,7 @@ export const useUserStore = defineStore('user', () => {
95
96
  const role = ref<string | null>(null)
96
97
  const organizationRole = ref<string | null>(null)
97
98
  const activeOrganizationFromPermissions = ref<Organization | null>(null)
99
+ const activeOrganizationIsPersonal = ref(false)
98
100
  const isLoading = ref(false)
99
101
  const activeOrganization = computed(() => {
100
102
  // Prefer the one from permissions API if available, otherwise use organizationsHelper
@@ -201,6 +203,7 @@ export const useUserStore = defineStore('user', () => {
201
203
  role.value = data.role
202
204
  organizationRole.value = data.organizationRole
203
205
  activeOrganizationFromPermissions.value = data.activeOrganization
206
+ activeOrganizationIsPersonal.value = data.isPersonalClient
204
207
  } else {
205
208
  console.warn('Permissions API returned no data. Clearing user data as a precaution.')
206
209
  clearUserData() // Clear data if API returns nothing, as state is uncertain
@@ -217,6 +220,7 @@ export const useUserStore = defineStore('user', () => {
217
220
  role.value = null
218
221
  organizationRole.value = null
219
222
  activeOrganizationFromPermissions.value = null
223
+ activeOrganizationIsPersonal.value = false
220
224
  currentSession.value = null
221
225
  currentUser.value = null
222
226
  activeOrganizationRoleValue.value = null
@@ -445,6 +449,7 @@ export const useUserStore = defineStore('user', () => {
445
449
  activeOrganization,
446
450
  activeOrganizationRole,
447
451
  activeOrganizationType,
452
+ activeOrganizationIsPersonal,
448
453
  myOrganizations,
449
454
  loadingOrganization,
450
455
  refreshOrganizations,
@@ -1,5 +1,5 @@
1
1
  import { createAuthClient } from 'better-auth/vue' // Use the Vue-specific import
2
- import { adminClient, emailOTPClient, organizationClient } from 'better-auth/client/plugins'
2
+ import { adminClient, emailOTPClient, organizationClient, inferAdditionalFields } from 'better-auth/client/plugins'
3
3
  import { ac, user, admin as adminRole } from '@nuxt-customer-portal/core/shared/permissions'
4
4
 
5
5
  const baseURL = process.env.BETTER_AUTH_URL
@@ -7,6 +7,9 @@ const baseURL = process.env.BETTER_AUTH_URL
7
7
  export const authClient = createAuthClient({
8
8
  baseURL: baseURL,
9
9
  plugins: [
10
+ inferAdditionalFields({
11
+ user: { firstName: { type: 'string', required: false }, lastName: { type: 'string', required: false } }
12
+ }),
10
13
  adminClient({
11
14
  ac,
12
15
  roles: {
@@ -5,9 +5,12 @@
5
5
  "edit": "Change invitation role",
6
6
  "role": "Role",
7
7
  "save": "Save role",
8
+ "resend": "Resend invitation",
8
9
  "revoke": "Revoke invitation",
9
10
  "confirmRevoke": "Revoke the invitation for {email}? This invitation link will no longer work.",
10
11
  "updated": "Invitation role updated",
12
+ "resent": "Invitation resent",
13
+ "resendFailed": "The invitation could not be resent. Try again.",
11
14
  "revoked": "Invitation revoked",
12
15
  "failed": "Could not change this invitation. Refresh the page and try again."
13
16
  },
@@ -15,6 +18,7 @@
15
18
  "refresh": "Refresh",
16
19
  "cancel": "Cancel",
17
20
  "close": "Close",
21
+ "collapseSidebar": "Collapse sidebar",
18
22
  "confirm": "Confirm",
19
23
  "create": "Create",
20
24
  "createdAt": "Created at",
@@ -22,6 +26,7 @@
22
26
  "descending": "Descending",
23
27
  "direction": "Direction",
24
28
  "edit": "Edit",
29
+ "expandSidebar": "Expand sidebar",
25
30
  "error": "An error occurred",
26
31
  "filters": "Filters",
27
32
  "loaded": "Loaded",
@@ -77,5 +82,22 @@
77
82
  "admin": "Admin",
78
83
  "blog": "Blog",
79
84
  "contact": "Contact"
85
+ },
86
+ "timezones": {
87
+ "label": "Timezone",
88
+ "provider": "Business timezone",
89
+ "preference": "Display timezone",
90
+ "inherit": "Use default timezone ({timezone})",
91
+ "invalid": "Choose a valid timezone",
92
+ "savedPreference": "Your saved timezone stays the same when you travel.",
93
+ "current": "Currently: {timezone}",
94
+ "useDevice": "Use device timezone",
95
+ "save": "Save",
96
+ "saved": "Timezone saved",
97
+ "failed": "Could not save timezone",
98
+ "openPersonalAccount": "Open personal account",
99
+ "personalAccount": "Personal account",
100
+ "selectAccount": "Select account",
101
+ "accountSettings": "Account details"
80
102
  }
81
103
  }
@@ -5,9 +5,12 @@
5
5
  "edit": "Rol van uitnodiging wijzigen",
6
6
  "role": "Rol",
7
7
  "save": "Rol opslaan",
8
+ "resend": "Uitnodiging opnieuw versturen",
8
9
  "revoke": "Uitnodiging intrekken",
9
10
  "confirmRevoke": "De uitnodiging voor {email} intrekken? Deze uitnodigingslink werkt daarna niet meer.",
10
11
  "updated": "Rol van uitnodiging gewijzigd",
12
+ "resent": "Uitnodiging opnieuw verstuurd",
13
+ "resendFailed": "De uitnodiging kon niet opnieuw worden verstuurd. Probeer het opnieuw.",
11
14
  "revoked": "Uitnodiging ingetrokken",
12
15
  "failed": "Deze uitnodiging kon niet worden gewijzigd. Vernieuw de pagina en probeer opnieuw."
13
16
  },
@@ -15,12 +18,14 @@
15
18
  "refresh": "Verversen",
16
19
  "cancel": "Annuleren",
17
20
  "close": "Sluiten",
21
+ "collapseSidebar": "Zijbalk inklappen",
18
22
  "create": "Aanmaken",
19
23
  "createdAt": "Aangemaakt op",
20
24
  "delete": "Verwijderen",
21
25
  "descending": "Aflopend",
22
26
  "direction": "Richting",
23
27
  "edit": "Bewerken",
28
+ "expandSidebar": "Zijbalk uitklappen",
24
29
  "error": "Er is een fout opgetreden",
25
30
  "filters": "Filters",
26
31
  "loaded": "Geladen",
@@ -77,5 +82,22 @@
77
82
  "admin": "Beheer",
78
83
  "blog": "Blog",
79
84
  "contact": "Contact"
85
+ },
86
+ "timezones": {
87
+ "label": "Tijdzone",
88
+ "provider": "Tijdzone van het bedrijf",
89
+ "preference": "Weergavetijdzone",
90
+ "inherit": "Standaardtijdzone gebruiken ({timezone})",
91
+ "invalid": "Kies een geldige tijdzone",
92
+ "savedPreference": "Je opgeslagen tijdzone blijft hetzelfde wanneer je reist.",
93
+ "current": "Momenteel: {timezone}",
94
+ "useDevice": "Tijdzone van apparaat gebruiken",
95
+ "save": "Opslaan",
96
+ "saved": "Tijdzone opgeslagen",
97
+ "failed": "Tijdzone opslaan mislukt",
98
+ "openPersonalAccount": "Persoonlijk account openen",
99
+ "personalAccount": "Persoonlijk account",
100
+ "selectAccount": "Account selecteren",
101
+ "accountSettings": "Accountgegevens"
80
102
  }
81
103
  }
@@ -0,0 +1,22 @@
1
+ ALTER TABLE "user" ADD COLUMN timezone text;
2
+ CREATE TABLE organization_settings (
3
+ organization_id text PRIMARY KEY REFERENCES organization(id) ON DELETE CASCADE,
4
+ timezone text NOT NULL DEFAULT 'Europe/Amsterdam'
5
+ );
6
+ INSERT INTO organization_settings (organization_id) SELECT id FROM organization WHERE organization_type = 'PROVIDER';
7
+ DO $$ BEGIN
8
+ IF to_regclass('timesheets.workspace_settings') IS NOT NULL THEN
9
+ EXECUTE 'UPDATE organization_settings s SET timezone = w.timezone FROM timesheets.workspace_settings w WHERE w.organization_id = s.organization_id';
10
+ END IF;
11
+ END $$;
12
+
13
+ -- Bootstrap paths can create the provider after migrations have completed.
14
+ CREATE FUNCTION initialize_provider_timezone() RETURNS trigger LANGUAGE plpgsql AS $$
15
+ BEGIN
16
+ IF NEW.organization_type = 'PROVIDER' THEN
17
+ INSERT INTO organization_settings (organization_id) VALUES (NEW.id) ON CONFLICT DO NOTHING;
18
+ END IF;
19
+ RETURN NEW;
20
+ END $$;
21
+ CREATE TRIGGER initialize_provider_timezone AFTER INSERT OR UPDATE OF organization_type ON organization
22
+ FOR EACH ROW EXECUTE FUNCTION initialize_provider_timezone();
@@ -0,0 +1,2 @@
1
+ ALTER TABLE "user" ADD COLUMN IF NOT EXISTS first_name text;
2
+ ALTER TABLE "user" ADD COLUMN IF NOT EXISTS last_name text;
@@ -0,0 +1,27 @@
1
+ CREATE TABLE IF NOT EXISTS "apikey" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "config_id" text DEFAULT 'default' NOT NULL,
4
+ "name" text,
5
+ "start" text,
6
+ "reference_id" text NOT NULL REFERENCES "organization"("id") ON DELETE CASCADE,
7
+ "prefix" text,
8
+ "key" text NOT NULL,
9
+ "refill_interval" integer,
10
+ "refill_amount" integer,
11
+ "last_refill_at" timestamp,
12
+ "enabled" boolean DEFAULT true NOT NULL,
13
+ "rate_limit_enabled" boolean DEFAULT true NOT NULL,
14
+ "rate_limit_time_window" integer,
15
+ "rate_limit_max" integer,
16
+ "request_count" integer DEFAULT 0 NOT NULL,
17
+ "remaining" integer,
18
+ "last_request" timestamp,
19
+ "expires_at" timestamp,
20
+ "created_at" timestamp DEFAULT now() NOT NULL,
21
+ "updated_at" timestamp DEFAULT now() NOT NULL,
22
+ "permissions" text,
23
+ "metadata" text
24
+ );
25
+ CREATE INDEX IF NOT EXISTS "apikey_config_id_idx" ON "apikey" ("config_id");
26
+ CREATE INDEX IF NOT EXISTS "apikey_reference_id_idx" ON "apikey" ("reference_id");
27
+ CREATE UNIQUE INDEX IF NOT EXISTS "apikey_key_uidx" ON "apikey" ("key");
package/nuxt.config.ts CHANGED
@@ -1,13 +1,24 @@
1
1
  import { fileURLToPath } from 'node:url'
2
+ import { socialAuthProviderEnabled } from './shared/social-auth'
2
3
 
3
- const envFlag = (value: string | undefined, fallback = true) => (value === undefined ? fallback : value === 'true')
4
4
  const registrationMode = ['open', 'invitation-only', 'disabled'].includes(process.env.PORTAL_REGISTRATION_MODE || '')
5
5
  ? (process.env.PORTAL_REGISTRATION_MODE as 'open' | 'invitation-only' | 'disabled')
6
6
  : 'open'
7
+ const githubEnabled = socialAuthProviderEnabled(
8
+ process.env.PORTAL_GITHUB_ENABLED,
9
+ process.env.GITHUB_CLIENT_ID,
10
+ process.env.GITHUB_CLIENT_SECRET
11
+ )
12
+ const googleEnabled = socialAuthProviderEnabled(
13
+ process.env.PORTAL_GOOGLE_ENABLED,
14
+ process.env.GOOGLE_CLIENT_ID,
15
+ process.env.GOOGLE_CLIENT_SECRET
16
+ )
7
17
 
8
18
  export default defineNuxtConfig({
9
19
  $meta: { name: 'nuxt-customer-portal-core' },
10
20
  compatibilityDate: '2025-10-24',
21
+ components: [{ path: fileURLToPath(new URL('./app/components', import.meta.url)), global: true }],
11
22
  modules: ['@nuxtjs/i18n', '@vueuse/nuxt'],
12
23
  i18n: {
13
24
  defaultLocale: 'en',
@@ -33,14 +44,14 @@ export default defineNuxtConfig({
33
44
  },
34
45
  portalAuth: {
35
46
  registrationMode,
36
- githubEnabled: envFlag(process.env.PORTAL_GITHUB_ENABLED),
37
- googleEnabled: envFlag(process.env.PORTAL_GOOGLE_ENABLED)
47
+ githubEnabled,
48
+ googleEnabled
38
49
  },
39
50
  public: {
40
51
  portalAuth: {
41
52
  registrationMode,
42
- githubEnabled: envFlag(process.env.PORTAL_GITHUB_ENABLED),
43
- googleEnabled: envFlag(process.env.PORTAL_GOOGLE_ENABLED),
53
+ githubEnabled,
54
+ googleEnabled,
44
55
  termsUrl: process.env.PORTAL_TERMS_URL || '/'
45
56
  }
46
57
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nuxt-customer-portal/core",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "homepage": "https://nuxt-customer-portal.com",
6
6
  "repository": {
@@ -37,7 +37,11 @@
37
37
  "access": "public"
38
38
  },
39
39
  "dependencies": {
40
+ "@better-auth/api-key": "1.6.25",
41
+ "@better-auth/core": "1.6.25",
42
+ "@better-auth/utils": "0.4.2",
40
43
  "@internationalized/date": "^3.10.0",
44
+ "@nuxt/ui": "^4.10.0",
41
45
  "@nuxtjs/i18n": "^10.6.0",
42
46
  "@pinia/nuxt": "^1.0.1",
43
47
  "@standard-schema/spec": "^1.1.0",
@@ -45,15 +49,16 @@
45
49
  "@vueuse/core": "^14.4.0",
46
50
  "@vueuse/nuxt": "^14.4.0",
47
51
  "better-auth": "1.6.25",
52
+ "better-call": "1.3.7",
48
53
  "date-fns": "^4.4.0",
49
54
  "drizzle-orm": "^0.45.2",
50
55
  "h3": "^1.15.4",
56
+ "marked": "^16.2.1",
51
57
  "nanoid": "^5.1.6",
52
58
  "pg": "^8.22.0",
53
59
  "pinia": "^4.0.2",
54
60
  "resend": "^6.18.1",
55
- "zod": "^4.4.3",
56
- "@nuxt/ui": "^4.10.0"
61
+ "zod": "^4.4.3"
57
62
  },
58
63
  "peerDependencies": {
59
64
  "nuxt": "^4.5.1",
@@ -1,6 +1,6 @@
1
1
  export default {
2
2
  id: 'core',
3
- version: '0.3.2',
3
+ version: '0.4.0',
4
4
  source: '@nuxt-customer-portal/core',
5
5
  dependsOn: [],
6
6
  schema: './server/db/schema/index.ts',
@@ -2,6 +2,7 @@ import { defineEventHandler, createError } from 'h3'
2
2
  import { statement } from '@nuxt-customer-portal/core/shared/permissions'
3
3
  import { auth } from '@nuxt-customer-portal/core/server/utils/auth'
4
4
  import { getUserPermissions } from '@nuxt-customer-portal/core/server/utils/permissions'
5
+ import { isPersonalClient } from '@nuxt-customer-portal/core/server/utils/client-account-policy'
5
6
 
6
7
  defineRouteMeta({
7
8
  openAPI: {
@@ -59,6 +60,8 @@ export default defineEventHandler(async (event) => {
59
60
  role: currentRole,
60
61
  organizationRole: orgRole || null,
61
62
  activeOrganization: activeOrganization,
62
- organizationType: activeOrganization?.organizationType ?? null
63
+ organizationType: activeOrganization?.organizationType ?? null,
64
+ isPersonalClient:
65
+ activeOrganization?.organizationType === 'CLIENT' && (await isPersonalClient(activeOrganization.id))
63
66
  }
64
67
  })
@@ -0,0 +1,9 @@
1
+ import { requireSession, requireOrganizationContext } from '../portal'
2
+ import { getActiveOrganizationId } from '../../shared/portal-session'
3
+ import { getResolvedTimezones } from '../utils/timezones'
4
+
5
+ export default defineEventHandler(async (event) => {
6
+ const session = await requireSession(event)
7
+ const context = getActiveOrganizationId(session) ? await requireOrganizationContext(session) : null
8
+ return getResolvedTimezones(session.user.id, context?.organizationType === 'CLIENT' ? context.organizationId : null)
9
+ })
@@ -1,7 +1,7 @@
1
1
  // Core authentication and organization schema owned by portal core.
2
2
 
3
3
  import { sql } from 'drizzle-orm'
4
- import { pgTable, text, timestamp, boolean, check, index, uniqueIndex, jsonb } from 'drizzle-orm/pg-core'
4
+ import { pgTable, text, timestamp, boolean, check, index, uniqueIndex, jsonb, integer } from 'drizzle-orm/pg-core'
5
5
 
6
6
  export const user = pgTable(
7
7
  'user',
@@ -9,6 +9,9 @@ export const user = pgTable(
9
9
  id: text('id').primaryKey(),
10
10
  name: text('name').notNull(),
11
11
  email: text('email').notNull().unique(),
12
+ timezone: text('timezone'),
13
+ firstName: text('first_name'),
14
+ lastName: text('last_name'),
12
15
  emailVerified: boolean('email_verified').default(false).notNull(),
13
16
  image: text('image'),
14
17
  createdAt: timestamp('created_at').defaultNow().notNull(),
@@ -115,6 +118,13 @@ export const organization = pgTable(
115
118
  ]
116
119
  )
117
120
 
121
+ export const organizationSettings = pgTable('organization_settings', {
122
+ organizationId: text('organization_id')
123
+ .primaryKey()
124
+ .references(() => organization.id, { onDelete: 'cascade' }),
125
+ timezone: text('timezone').default('Europe/Amsterdam').notNull()
126
+ })
127
+
118
128
  export const member = pgTable(
119
129
  'member',
120
130
  {
@@ -196,3 +206,38 @@ export const invitation = pgTable(
196
206
  index('invitation_email_idx').on(table.email)
197
207
  ]
198
208
  )
209
+
210
+ export const apiKey = pgTable(
211
+ 'apikey',
212
+ {
213
+ id: text('id').primaryKey(),
214
+ configId: text('config_id').default('default').notNull(),
215
+ name: text('name'),
216
+ start: text('start'),
217
+ referenceId: text('reference_id')
218
+ .notNull()
219
+ .references(() => organization.id, { onDelete: 'cascade' }),
220
+ prefix: text('prefix'),
221
+ key: text('key').notNull(),
222
+ refillInterval: integer('refill_interval'),
223
+ refillAmount: integer('refill_amount'),
224
+ lastRefillAt: timestamp('last_refill_at'),
225
+ enabled: boolean('enabled').default(true).notNull(),
226
+ rateLimitEnabled: boolean('rate_limit_enabled').default(true).notNull(),
227
+ rateLimitTimeWindow: integer('rate_limit_time_window'),
228
+ rateLimitMax: integer('rate_limit_max'),
229
+ requestCount: integer('request_count').default(0).notNull(),
230
+ remaining: integer('remaining'),
231
+ lastRequest: timestamp('last_request'),
232
+ expiresAt: timestamp('expires_at'),
233
+ createdAt: timestamp('created_at').defaultNow().notNull(),
234
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
235
+ permissions: text('permissions'),
236
+ metadata: text('metadata')
237
+ },
238
+ (table) => [
239
+ index('apikey_config_id_idx').on(table.configId),
240
+ index('apikey_reference_id_idx').on(table.referenceId),
241
+ uniqueIndex('apikey_key_uidx').on(table.key)
242
+ ]
243
+ )
@@ -1,5 +1,7 @@
1
1
  import { defineEventHandler, type H3Event, createError } from 'h3'
2
2
  import { auth } from '../utils/auth'
3
+ import { isPublicStoreRoute } from '../utils/public-api-route'
4
+ import { isPublicInvitationRoute } from '../utils/public-invitation-route'
3
5
 
4
6
  async function isAuthenticated(event: H3Event): Promise<boolean> {
5
7
  const session = await auth.api.getSession({
@@ -17,6 +19,14 @@ export default defineEventHandler(async (event) => {
17
19
  if (!url || !url.startsWith('/api/')) {
18
20
  return
19
21
  }
22
+ // Products routes implement their own catalog-key, signature, or public-entry checks.
23
+ const pathname = new URL(url, 'http://localhost').pathname
24
+ if (isPublicStoreRoute(event.method, pathname)) {
25
+ return
26
+ }
27
+ if (isPublicInvitationRoute(event.method, pathname)) {
28
+ return
29
+ }
20
30
  const unprotectedPaths = [
21
31
  '/api/auth/',
22
32
  '/api/_nuxt_icon',