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