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 @@