From 25c6c6750a1e29907c60ec2d748b48d6487fc31b Mon Sep 17 00:00:00 2001
From: Claude
Date: Tue, 9 Jun 2026 19:46:32 +0000
Subject: [PATCH] feat: per-user pro feature toggles
Licensed features can now be granted or revoked per user instead of
applying instance-wide. Resolution is layered: the instance license must
include the feature, then a per-user override, an admin-set instance
default and the built-in code default are consulted in that order.
Time tracking is the first per-user toggleable feature; admin_panel and
audit_logs stay instance-wide. New features opt in via the
perUserToggleable map in pkg/license.
- store per-user overrides in a json column on users, instance defaults
in the new pro_feature_instance_defaults table
- enforce the toggle in the v2 time-entries route gate and in the
TimeEntry permission chokepoint for non-route callers
- new admin v2 endpoints to manage instance defaults and per-user
overrides
- expose effective_pro_features on /api/v1/user; the frontend prefers it
over /info's instance-wide list once the user is loaded
- admin UI: per-user toggles on the user detail modal, instance defaults
on the admin overview
https://claude.ai/code/session_01AVt4FHWrUUhv5p6yn99pdp
---
frontend/src/helpers/fetcher.ts | 9 +
frontend/src/i18n/lang/en.json | 21 +-
frontend/src/modelTypes/IUser.ts | 4 +
frontend/src/models/user.ts | 1 +
.../src/services/admin/proFeatureService.ts | 70 +++++
frontend/src/services/timeEntry.ts | 11 +-
frontend/src/stores/config.test.ts | 24 ++
frontend/src/stores/config.ts | 8 +
frontend/src/views/admin/OverviewView.vue | 66 ++++-
frontend/src/views/admin/UsersView.vue | 102 ++++++-
.../pro_feature_instance_defaults.yml | 1 +
pkg/license/license.go | 41 +++
pkg/migration/20260609191533.go | 61 ++++
pkg/models/models.go | 1 +
pkg/models/pro_features.go | 241 ++++++++++++++++
pkg/models/pro_features_test.go | 211 ++++++++++++++
pkg/models/setup_tests.go | 1 +
pkg/models/time_tracking.go | 19 +-
pkg/models/time_tracking_test.go | 23 ++
pkg/routes/api/v1/user_show.go | 11 +
pkg/routes/api/v2/admin_pro_features.go | 246 ++++++++++++++++
pkg/routes/api/v2/time_entries.go | 30 +-
pkg/user/user.go | 5 +
pkg/webtests/huma_admin_pro_features_test.go | 264 ++++++++++++++++++
24 files changed, 1452 insertions(+), 19 deletions(-)
create mode 100644 frontend/src/services/admin/proFeatureService.ts
create mode 100644 pkg/db/fixtures/pro_feature_instance_defaults.yml
create mode 100644 pkg/migration/20260609191533.go
create mode 100644 pkg/models/pro_features.go
create mode 100644 pkg/models/pro_features_test.go
create mode 100644 pkg/routes/api/v2/admin_pro_features.go
create mode 100644 pkg/webtests/huma_admin_pro_features_test.go
diff --git a/frontend/src/helpers/fetcher.ts b/frontend/src/helpers/fetcher.ts
index e243dc0ce..1097f8af3 100644
--- a/frontend/src/helpers/fetcher.ts
+++ b/frontend/src/helpers/fetcher.ts
@@ -11,6 +11,15 @@ export function getApiBaseUrl(): string {
return url?.endsWith('/') ? url : url + '/'
}
+// The shared AuthenticatedHTTPFactory pins baseURL to /api/v1; /api/v2
+// endpoints hand axios absolute URLs to bypass that. Bespoke and intentionally
+// a bit dirty — to be folded into the proper service layer once the frontend
+// moves fully onto v2.
+export function getApiV2Url(path: string): string {
+ const v2Base = getApiBaseUrl().replace(/\/api\/v1\/$/, '/api/v2/')
+ return new URL(v2Base + path, window.location.origin).toString()
+}
+
export function HTTPFactory() {
const instance = axios.create({
baseURL: getApiBaseUrl(),
diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json
index a48d793ac..0efeaa06c 100644
--- a/frontend/src/i18n/lang/en.json
+++ b/frontend/src/i18n/lang/en.json
@@ -1552,7 +1552,13 @@
"deleteModeScheduled": "Schedule deletion",
"deleteModeScheduledHelp": "Schedule deletion sends the user a confirmation email, mirroring a self-initiated account deletion.",
"deleteModeNow": "Delete now",
- "deleteModeNowHelp": "Delete now removes the user and all their data immediately. This cannot be undone."
+ "deleteModeNowHelp": "Delete now removes the user and all their data immediately. This cannot be undone.",
+ "proFeaturesTitle": "Pro features",
+ "featureDefault": "Default ({state})",
+ "featureEnabled": "Enabled",
+ "featureDisabled": "Disabled",
+ "featureStateOn": "on",
+ "featureStateOff": "off"
},
"projects": {
"ownerLabel": "Owner",
@@ -1560,6 +1566,19 @@
"reassignTitle": "Reassign {title}",
"reassignedSuccess": "Project owner reassigned.",
"newOwnerLabel": "New owner"
+ },
+ "proFeatures": {
+ "title": "Pro features",
+ "features": {
+ "time_tracking": "Time tracking",
+ "admin_panel": "Admin panel",
+ "audit_logs": "Audit logs"
+ },
+ "defaultLabel": "{feature} for users without an override",
+ "defaultEnabled": "Enabled by default",
+ "defaultDisabled": "Disabled by default",
+ "defaultUpdatedSuccess": "Default for {feature} updated.",
+ "notLicensed": "not licensed"
}
}
}
diff --git a/frontend/src/modelTypes/IUser.ts b/frontend/src/modelTypes/IUser.ts
index ae2f6ee45..4edc57779 100644
--- a/frontend/src/modelTypes/IUser.ts
+++ b/frontend/src/modelTypes/IUser.ts
@@ -25,4 +25,8 @@ export interface IUser extends IAbstract {
deletionScheduledAt: string | Date | null
isAdmin?: boolean
botOwnerId?: number
+ // The pro features effectively enabled for this user (license + per-user
+ // toggles). Undefined until the full user object is loaded from /user —
+ // the JWT payload doesn't carry it.
+ effectiveProFeatures?: string[]
}
diff --git a/frontend/src/models/user.ts b/frontend/src/models/user.ts
index e3fe2b128..0a41c659d 100644
--- a/frontend/src/models/user.ts
+++ b/frontend/src/models/user.ts
@@ -83,6 +83,7 @@ export default class UserModel extends AbstractModel implements IUser {
deletionScheduledAt: null
isAdmin?: boolean
botOwnerId = 0
+ effectiveProFeatures?: string[]
constructor(data: Partial = {}) {
super()
diff --git a/frontend/src/services/admin/proFeatureService.ts b/frontend/src/services/admin/proFeatureService.ts
new file mode 100644
index 000000000..999ee9b1d
--- /dev/null
+++ b/frontend/src/services/admin/proFeatureService.ts
@@ -0,0 +1,70 @@
+import {AuthenticatedHTTPFactory, getApiV2Url} from '@/helpers/fetcher'
+
+import type {ProFeature} from '@/constants/proFeatures'
+
+export interface ProFeatureState {
+ feature: ProFeature
+ licensed: boolean
+ perUserToggleable: boolean
+ defaultEnabled: boolean
+ defaultSource: 'code' | 'instance'
+}
+
+export interface UserProFeatureState {
+ feature: ProFeature
+ override: boolean | null
+ effective: boolean
+}
+
+function parseProFeatureState(raw: Record): ProFeatureState {
+ return {
+ feature: raw.feature as ProFeature,
+ licensed: !!raw.licensed,
+ perUserToggleable: !!raw.per_user_toggleable,
+ defaultEnabled: !!raw.default_enabled,
+ defaultSource: raw.default_source as 'code' | 'instance',
+ }
+}
+
+function parseUserProFeatureState(raw: Record): UserProFeatureState {
+ return {
+ feature: raw.feature as ProFeature,
+ override: (raw.override ?? null) as boolean | null,
+ effective: !!raw.effective,
+ }
+}
+
+export function useAdminProFeatureService() {
+ const http = AuthenticatedHTTPFactory()
+
+ async function getAll(): Promise {
+ const {data} = await http.get(getApiV2Url('admin/pro-features'))
+ return (data ?? []).map(parseProFeatureState)
+ }
+
+ async function setInstanceDefault(feature: ProFeature, defaultEnabled: boolean): Promise {
+ const {data} = await http.put(getApiV2Url(`admin/pro-features/${feature}`), {default_enabled: defaultEnabled})
+ return (data ?? []).map(parseProFeatureState)
+ }
+
+ async function resetInstanceDefault(feature: ProFeature): Promise {
+ await http.delete(getApiV2Url(`admin/pro-features/${feature}`))
+ }
+
+ async function getForUser(userId: number): Promise {
+ const {data} = await http.get(getApiV2Url(`admin/users/${userId}/pro-features`))
+ return (data ?? []).map(parseUserProFeatureState)
+ }
+
+ async function setUserOverride(userId: number, feature: ProFeature, enabled: boolean): Promise {
+ const {data} = await http.put(getApiV2Url(`admin/users/${userId}/pro-features/${feature}`), {enabled})
+ return (data ?? []).map(parseUserProFeatureState)
+ }
+
+ async function clearUserOverride(userId: number, feature: ProFeature): Promise {
+ const {data} = await http.delete(getApiV2Url(`admin/users/${userId}/pro-features/${feature}`))
+ return (data ?? []).map(parseUserProFeatureState)
+ }
+
+ return {getAll, setInstanceDefault, resetInstanceDefault, getForUser, setUserOverride, clearUserOverride}
+}
diff --git a/frontend/src/services/timeEntry.ts b/frontend/src/services/timeEntry.ts
index b76a3d11a..6b9a95f6c 100644
--- a/frontend/src/services/timeEntry.ts
+++ b/frontend/src/services/timeEntry.ts
@@ -1,17 +1,8 @@
-import {AuthenticatedHTTPFactory, getApiBaseUrl} from '@/helpers/fetcher'
+import {AuthenticatedHTTPFactory, getApiV2Url as v2Url} from '@/helpers/fetcher'
import {objectToCamelCase, objectToSnakeCase} from '@/helpers/case'
import type {ITimeEntry} from '@/modelTypes/ITimeEntry'
-// Time tracking is the first frontend feature on /api/v2, while the shared
-// AuthenticatedHTTPFactory pins baseURL to /api/v1. We hand axios absolute v2
-// URLs to bypass that. Bespoke and intentionally a bit dirty — to be folded
-// into the proper service layer once the frontend moves fully onto v2.
-function v2Url(path: string): string {
- const v2Base = getApiBaseUrl().replace(/\/api\/v1\/$/, '/api/v2/')
- return new URL(v2Base + path, window.location.origin).toString()
-}
-
export function parseTimeEntry(raw: Record): ITimeEntry {
const e = objectToCamelCase(raw)
const end = e.endTime as string | null | undefined
diff --git a/frontend/src/stores/config.test.ts b/frontend/src/stores/config.test.ts
index 1d16c8a9d..3d1b609da 100644
--- a/frontend/src/stores/config.test.ts
+++ b/frontend/src/stores/config.test.ts
@@ -3,6 +3,8 @@ import {setActivePinia, createPinia} from 'pinia'
import {computed} from 'vue'
import {useConfigStore} from './config'
+import {useAuthStore} from './auth'
+import UserModel from '@/models/user'
describe('config store', () => {
beforeEach(() => {
@@ -36,5 +38,27 @@ describe('config store', () => {
store.enabledProFeatures = ['admin_panel']
expect(enabled.value).toBe(true)
})
+
+ it('prefers the per-user effective list once the user is loaded', () => {
+ const store = useConfigStore()
+ const authStore = useAuthStore()
+ store.enabledProFeatures = ['time_tracking']
+
+ authStore.setUser(new UserModel({id: 1, effectiveProFeatures: []}), false)
+ expect(store.isProFeatureEnabled('time_tracking')).toBe(false)
+
+ authStore.setUser(new UserModel({id: 1, effectiveProFeatures: ['time_tracking']}), false)
+ expect(store.isProFeatureEnabled('time_tracking')).toBe(true)
+ })
+
+ it('falls back to the instance list while the per-user list is unknown', () => {
+ const store = useConfigStore()
+ const authStore = useAuthStore()
+ store.enabledProFeatures = ['time_tracking']
+
+ // A JWT-derived user has no effectiveProFeatures yet.
+ authStore.setUser(new UserModel({id: 1}), false)
+ expect(store.isProFeatureEnabled('time_tracking')).toBe(true)
+ })
})
})
diff --git a/frontend/src/stores/config.ts b/frontend/src/stores/config.ts
index 3eea0595a..b9f9066f1 100644
--- a/frontend/src/stores/config.ts
+++ b/frontend/src/stores/config.ts
@@ -9,6 +9,7 @@ import type {IProvider} from '@/types/IProvider'
import type {MIGRATORS} from '@/views/migrate/migrators'
import type {ProFeature} from '@/constants/proFeatures'
import {InvalidApiUrlProvidedError} from '@/helpers/checkAndSetApiUrl'
+import {useAuthStore} from '@/stores/auth'
export interface ConfigState {
version: string,
@@ -106,6 +107,13 @@ export const useConfigStore = defineStore('config', () => {
}
function isProFeatureEnabled(name: ProFeature): boolean {
+ // The per-user list from /user is authoritative once loaded; the
+ // instance list from /info covers the time before login (or before
+ // the full user object arrives).
+ const effective = useAuthStore().info?.effectiveProFeatures
+ if (effective) {
+ return effective.includes(name)
+ }
return state.enabledProFeatures?.includes(name) ?? false
}
diff --git a/frontend/src/views/admin/OverviewView.vue b/frontend/src/views/admin/OverviewView.vue
index 363828690..4b979b99c 100644
--- a/frontend/src/views/admin/OverviewView.vue
+++ b/frontend/src/views/admin/OverviewView.vue
@@ -115,6 +115,35 @@
+
+
+ {{ $t('admin.proFeatures.title') }}
+
+
+
+
+
+ ({{ $t('admin.proFeatures.notLicensed') }})
+
+
+
+
@@ -122,21 +151,55 @@