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') }} +

+ + + +
@@ -122,21 +151,55 @@