Compare commits
1 Commits
main
...
claude/per
| Author | SHA1 | Date |
|---|---|---|
|
|
25c6c6750a |
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export default class UserModel extends AbstractModel<IUser> implements IUser {
|
|||
deletionScheduledAt: null
|
||||
isAdmin?: boolean
|
||||
botOwnerId = 0
|
||||
effectiveProFeatures?: string[]
|
||||
|
||||
constructor(data: Partial<IUser> = {}) {
|
||||
super()
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): 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<string, unknown>): 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<ProFeatureState[]> {
|
||||
const {data} = await http.get(getApiV2Url('admin/pro-features'))
|
||||
return (data ?? []).map(parseProFeatureState)
|
||||
}
|
||||
|
||||
async function setInstanceDefault(feature: ProFeature, defaultEnabled: boolean): Promise<ProFeatureState[]> {
|
||||
const {data} = await http.put(getApiV2Url(`admin/pro-features/${feature}`), {default_enabled: defaultEnabled})
|
||||
return (data ?? []).map(parseProFeatureState)
|
||||
}
|
||||
|
||||
async function resetInstanceDefault(feature: ProFeature): Promise<void> {
|
||||
await http.delete(getApiV2Url(`admin/pro-features/${feature}`))
|
||||
}
|
||||
|
||||
async function getForUser(userId: number): Promise<UserProFeatureState[]> {
|
||||
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<UserProFeatureState[]> {
|
||||
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<UserProFeatureState[]> {
|
||||
const {data} = await http.delete(getApiV2Url(`admin/users/${userId}/pro-features/${feature}`))
|
||||
return (data ?? []).map(parseUserProFeatureState)
|
||||
}
|
||||
|
||||
return {getAll, setInstanceDefault, resetInstanceDefault, getForUser, setUserOverride, clearUserOverride}
|
||||
}
|
||||
|
|
@ -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<string, unknown>): ITimeEntry {
|
||||
const e = objectToCamelCase(raw)
|
||||
const end = e.endTime as string | null | undefined
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -115,6 +115,35 @@
|
|||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="toggleableFeatures.length"
|
||||
class="admin-overview__card admin-overview__card--wide"
|
||||
>
|
||||
<h2 class="admin-overview__card-title">
|
||||
{{ $t('admin.proFeatures.title') }}
|
||||
</h2>
|
||||
<FormField
|
||||
v-for="st in toggleableFeatures"
|
||||
:key="st.feature"
|
||||
:label="$t('admin.proFeatures.defaultLabel', {feature: featureLabel(st.feature)})"
|
||||
>
|
||||
<template #default="{id}">
|
||||
<FormSelect
|
||||
:id="id"
|
||||
:model-value="st.defaultEnabled ? 'on' : 'off'"
|
||||
:options="defaultOptions"
|
||||
:disabled="!st.licensed || savingFeature !== null"
|
||||
@update:modelValue="setFeatureDefault(st.feature, $event === 'on')"
|
||||
/>
|
||||
<span
|
||||
v-if="!st.licensed"
|
||||
class="admin-overview__hint"
|
||||
>
|
||||
({{ $t('admin.proFeatures.notLicensed') }})
|
||||
</span>
|
||||
</template>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
|
@ -122,21 +151,55 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import {ref, computed, onMounted} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import dayjs from 'dayjs'
|
||||
import Card from '@/components/misc/Card.vue'
|
||||
import Icon from '@/components/misc/Icon'
|
||||
import TimeDisplay from '@/components/misc/TimeDisplay.vue'
|
||||
import FormField from '@/components/input/FormField.vue'
|
||||
import FormSelect from '@/components/input/FormSelect.vue'
|
||||
import AdminOverviewService from '@/services/admin/overviewService'
|
||||
import {useAdminProFeatureService, type ProFeatureState} from '@/services/admin/proFeatureService'
|
||||
import type {IAdminOverview} from '@/modelTypes/IAdminOverview'
|
||||
import type {ProFeature} from '@/constants/proFeatures'
|
||||
import {useConfigStore} from '@/stores/config'
|
||||
import {error} from '@/message'
|
||||
import {error, success} from '@/message'
|
||||
|
||||
const {t, te} = useI18n({useScope: 'global'})
|
||||
const adminOverviewService = new AdminOverviewService()
|
||||
const proFeatureService = useAdminProFeatureService()
|
||||
const configStore = useConfigStore()
|
||||
|
||||
const data = ref<IAdminOverview | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const featureStates = ref<ProFeatureState[]>([])
|
||||
const savingFeature = ref<ProFeature | null>(null)
|
||||
|
||||
const toggleableFeatures = computed(() => featureStates.value.filter(st => st.perUserToggleable))
|
||||
|
||||
const defaultOptions = computed(() => [
|
||||
{value: 'on', label: t('admin.proFeatures.defaultEnabled')},
|
||||
{value: 'off', label: t('admin.proFeatures.defaultDisabled')},
|
||||
])
|
||||
|
||||
function featureLabel(feature: ProFeature): string {
|
||||
const key = `admin.proFeatures.features.${feature}`
|
||||
return te(key) ? t(key) : feature
|
||||
}
|
||||
|
||||
async function setFeatureDefault(feature: ProFeature, enabled: boolean) {
|
||||
savingFeature.value = feature
|
||||
try {
|
||||
featureStates.value = await proFeatureService.setInstanceDefault(feature, enabled)
|
||||
success({message: t('admin.proFeatures.defaultUpdatedSuccess', {feature: featureLabel(feature)})})
|
||||
} catch (e) {
|
||||
error(e)
|
||||
} finally {
|
||||
savingFeature.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const expiresInDays = computed<number | null>(() => {
|
||||
const expiresAt = data.value?.license?.expiresAt
|
||||
if (!expiresAt) return null
|
||||
|
|
@ -153,6 +216,7 @@ onMounted(async () => {
|
|||
loading.value = true
|
||||
try {
|
||||
data.value = await adminOverviewService.getOverview()
|
||||
featureStates.value = await proFeatureService.getAll()
|
||||
} catch (e) {
|
||||
error(e)
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -119,6 +119,25 @@
|
|||
</template>
|
||||
</FormField>
|
||||
|
||||
<template v-if="featureStates.length">
|
||||
<h3 class="admin-users__features-title">
|
||||
{{ $t('admin.users.proFeaturesTitle') }}
|
||||
</h3>
|
||||
<FormField
|
||||
v-for="st in featureStates"
|
||||
:key="st.feature"
|
||||
:label="featureLabel(st.feature)"
|
||||
>
|
||||
<template #default="{id}">
|
||||
<FormSelect
|
||||
:id="id"
|
||||
v-model="featureChoices[st.feature]"
|
||||
:options="featureOptions(st.feature)"
|
||||
/>
|
||||
</template>
|
||||
</FormField>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<XButton
|
||||
variant="tertiary"
|
||||
|
|
@ -282,8 +301,10 @@ import {useDebounceFn} from '@vueuse/core'
|
|||
import {useI18n} from 'vue-i18n'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import AdminUserService, {type CreateAdminUserBody, type DeleteUserMode} from '@/services/admin/userService'
|
||||
import {useAdminProFeatureService, type UserProFeatureState} from '@/services/admin/proFeatureService'
|
||||
import AdminUserModel from '@/models/adminUser'
|
||||
import type {IAdminUser} from '@/modelTypes/IAdminUser'
|
||||
import type {ProFeature} from '@/constants/proFeatures'
|
||||
import {error, success} from '@/message'
|
||||
import Card from '@/components/misc/Card.vue'
|
||||
import Modal from '@/components/misc/Modal.vue'
|
||||
|
|
@ -295,11 +316,12 @@ import FormSelect from '@/components/input/FormSelect.vue'
|
|||
import FormCheckbox from '@/components/input/FormCheckbox.vue'
|
||||
import TimeDisplay from '@/components/misc/TimeDisplay.vue'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
const {t, te} = useI18n({useScope: 'global'})
|
||||
const authStore = useAuthStore()
|
||||
const currentUserId = computed(() => authStore.info?.id)
|
||||
|
||||
const adminUserService = new AdminUserService()
|
||||
const proFeatureService = useAdminProFeatureService()
|
||||
|
||||
const users = ref<IAdminUser[]>([])
|
||||
const loading = ref(false)
|
||||
|
|
@ -315,6 +337,12 @@ const createOpen = ref(false)
|
|||
const creating = ref(false)
|
||||
const editable = reactive({isAdmin: false, status: 0})
|
||||
|
||||
type FeatureChoice = 'default' | 'on' | 'off'
|
||||
const featureStates = ref<UserProFeatureState[]>([])
|
||||
const featureChoices = reactive<Record<string, FeatureChoice>>({})
|
||||
// Instance defaults, so the "Default" option can say what it resolves to.
|
||||
const instanceFeatureDefaults = reactive<Record<string, boolean>>({})
|
||||
|
||||
function emptyCreateForm(): Required<Pick<CreateAdminUserBody, 'username' | 'email'>> & CreateAdminUserBody {
|
||||
return {
|
||||
username: '',
|
||||
|
|
@ -329,18 +357,56 @@ function emptyCreateForm(): Required<Pick<CreateAdminUserBody, 'username' | 'ema
|
|||
|
||||
const createForm = reactive(emptyCreateForm())
|
||||
|
||||
function choiceFromOverride(override: boolean | null): FeatureChoice {
|
||||
if (override === null) return 'default'
|
||||
return override ? 'on' : 'off'
|
||||
}
|
||||
|
||||
const featureChanges = computed(() => featureStates.value.filter(st =>
|
||||
featureChoices[st.feature] !== undefined
|
||||
&& featureChoices[st.feature] !== choiceFromOverride(st.override),
|
||||
))
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
if (!detailTarget.value) return false
|
||||
return editable.isAdmin !== !!detailTarget.value.isAdmin
|
||||
|| editable.status !== detailTarget.value.status
|
||||
|| featureChanges.value.length > 0
|
||||
})
|
||||
|
||||
watch(detailTarget, (u) => {
|
||||
watch(detailTarget, async (u) => {
|
||||
if (!u) return
|
||||
editable.isAdmin = !!u.isAdmin
|
||||
editable.status = u.status
|
||||
|
||||
featureStates.value = []
|
||||
Object.keys(featureChoices).forEach(k => delete featureChoices[k])
|
||||
try {
|
||||
featureStates.value = await proFeatureService.getForUser(u.id)
|
||||
featureStates.value.forEach(st => {
|
||||
featureChoices[st.feature] = choiceFromOverride(st.override)
|
||||
})
|
||||
} catch (e) {
|
||||
error(e)
|
||||
}
|
||||
})
|
||||
|
||||
function featureLabel(feature: ProFeature): string {
|
||||
const key = `admin.proFeatures.features.${feature}`
|
||||
return te(key) ? t(key) : feature
|
||||
}
|
||||
|
||||
function featureOptions(feature: ProFeature) {
|
||||
const defaultState = instanceFeatureDefaults[feature]
|
||||
? t('admin.users.featureStateOn')
|
||||
: t('admin.users.featureStateOff')
|
||||
return [
|
||||
{value: 'default', label: t('admin.users.featureDefault', {state: defaultState})},
|
||||
{value: 'on', label: t('admin.users.featureEnabled')},
|
||||
{value: 'off', label: t('admin.users.featureDisabled')},
|
||||
]
|
||||
}
|
||||
|
||||
function statusLabel(status: number): string {
|
||||
switch (status) {
|
||||
case 0: return t('admin.users.statusActive')
|
||||
|
|
@ -439,6 +505,14 @@ async function saveChanges() {
|
|||
if (editable.status !== target.status) {
|
||||
latest = await adminUserService.setStatus(target.id, editable.status)
|
||||
}
|
||||
for (const st of featureChanges.value) {
|
||||
const choice = featureChoices[st.feature]
|
||||
if (choice === 'default') {
|
||||
await proFeatureService.clearUserOverride(target.id, st.feature)
|
||||
} else {
|
||||
await proFeatureService.setUserOverride(target.id, st.feature, choice === 'on')
|
||||
}
|
||||
}
|
||||
replaceUser(latest)
|
||||
success({message: t('admin.users.updatedSuccess', {username: latest.username})})
|
||||
detailTarget.value = null
|
||||
|
|
@ -478,7 +552,21 @@ async function doDelete(mode: DeleteUserMode) {
|
|||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
async function loadInstanceFeatureDefaults() {
|
||||
try {
|
||||
const states = await proFeatureService.getAll()
|
||||
states.filter(st => st.perUserToggleable).forEach(st => {
|
||||
instanceFeatureDefaults[st.feature] = st.defaultEnabled
|
||||
})
|
||||
} catch (e) {
|
||||
error(e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
loadInstanceFeatureDefaults()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
|
@ -488,6 +576,14 @@ onMounted(load)
|
|||
margin-block-end: 1rem;
|
||||
}
|
||||
|
||||
.admin-users__features-title {
|
||||
font-size: 0.85rem;
|
||||
color: var(--grey-600);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
margin-block: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.admin-users__meta {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
[]
|
||||
|
|
@ -80,6 +80,18 @@ var (
|
|||
"time_tracking": FeatureTimeTracking,
|
||||
"audit_logs": FeatureAuditLogs,
|
||||
}
|
||||
// perUserToggleable marks features admins can grant or revoke per user.
|
||||
// Features not listed here are always instance-wide (admin_panel gates the
|
||||
// admin surface itself, audit_logs is an instance property).
|
||||
perUserToggleable = map[Feature]bool{
|
||||
FeatureTimeTracking: true,
|
||||
}
|
||||
// perUserDefault is the code-level default for toggleable features when
|
||||
// neither an instance default nor a user override is set. time_tracking
|
||||
// defaults to enabled so licensed instances keep their behavior on upgrade.
|
||||
perUserDefault = map[Feature]bool{
|
||||
FeatureTimeTracking: true,
|
||||
}
|
||||
)
|
||||
|
||||
func (f *Feature) String() string {
|
||||
|
|
@ -291,6 +303,35 @@ func IsFeatureEnabled(feature Feature) bool {
|
|||
return st.Features[feature]
|
||||
}
|
||||
|
||||
// FeatureFromString resolves a feature key like "time_tracking" to its constant.
|
||||
func FeatureFromString(s string) (Feature, bool) {
|
||||
f, ok := stringToFeature[s]
|
||||
return f, ok
|
||||
}
|
||||
|
||||
// IsPerUserToggleable returns whether admins can grant or revoke the feature per user.
|
||||
func IsPerUserToggleable(feature Feature) bool {
|
||||
return perUserToggleable[feature]
|
||||
}
|
||||
|
||||
// PerUserDefault returns the code-level per-user default for a toggleable feature,
|
||||
// used when neither an instance default nor a user override is set.
|
||||
func PerUserDefault(feature Feature) bool {
|
||||
return perUserDefault[feature]
|
||||
}
|
||||
|
||||
// AllFeatures returns every known feature, sorted by their string key.
|
||||
func AllFeatures() []Feature {
|
||||
out := make([]Feature, 0, len(featureToString))
|
||||
for f := range featureToString {
|
||||
out = append(out, f)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i].String() < out[j].String()
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// MaxUsersReached returns whether the licensed user limit has been reached.
|
||||
// Returns false in free mode (no limit).
|
||||
func MaxUsersReached() bool {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package migration
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"src.techknowlogick.com/xormigrate"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type users20260609191533 struct {
|
||||
ProFeatureOverrides map[string]bool `xorm:"json null"`
|
||||
}
|
||||
|
||||
func (users20260609191533) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
|
||||
// Mirrors models.ProFeatureInstanceDefault.
|
||||
type proFeatureInstanceDefaults20260609191533 struct {
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk"`
|
||||
Feature string `xorm:"varchar(50) not null unique"`
|
||||
Enabled bool `xorm:"not null"`
|
||||
Created time.Time `xorm:"created not null"`
|
||||
Updated time.Time `xorm:"updated not null"`
|
||||
}
|
||||
|
||||
func (proFeatureInstanceDefaults20260609191533) TableName() string {
|
||||
return "pro_feature_instance_defaults"
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrations = append(migrations, &xormigrate.Migration{
|
||||
ID: "20260609191533",
|
||||
Description: "Add per-user pro feature toggles",
|
||||
Migrate: func(tx *xorm.Engine) error {
|
||||
if err := tx.Sync(users20260609191533{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Sync(proFeatureInstanceDefaults20260609191533{})
|
||||
},
|
||||
Rollback: func(tx *xorm.Engine) error {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -72,6 +72,7 @@ func GetTables() []interface{} {
|
|||
&Session{},
|
||||
&OAuthCode{},
|
||||
&TimeEntry{},
|
||||
&ProFeatureInstanceDefault{},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,241 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/license"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/api/pkg/web"
|
||||
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// ProFeatureInstanceDefault stores the admin-set instance-wide default for a
|
||||
// per-user toggleable pro feature. Without a row, the code default applies.
|
||||
type ProFeatureInstanceDefault struct {
|
||||
ID int64 `xorm:"bigint autoincr not null unique pk" json:"-"`
|
||||
Feature string `xorm:"varchar(50) not null unique" json:"feature"`
|
||||
Enabled bool `xorm:"not null" json:"enabled"`
|
||||
Created time.Time `xorm:"created not null" json:"created"`
|
||||
Updated time.Time `xorm:"updated not null" json:"updated"`
|
||||
}
|
||||
|
||||
func (ProFeatureInstanceDefault) TableName() string {
|
||||
return "pro_feature_instance_defaults"
|
||||
}
|
||||
|
||||
// GetProFeatureInstanceDefaults returns all admin-set instance defaults keyed
|
||||
// by feature string.
|
||||
func GetProFeatureInstanceDefaults(s *xorm.Session) (map[string]bool, error) {
|
||||
defaults := []*ProFeatureInstanceDefault{}
|
||||
if err := s.Find(&defaults); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]bool, len(defaults))
|
||||
for _, d := range defaults {
|
||||
out[d.Feature] = d.Enabled
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetProFeatureInstanceDefault upserts the instance-wide default for a feature.
|
||||
func SetProFeatureInstanceDefault(s *xorm.Session, feature license.Feature, enabled bool) error {
|
||||
existing := &ProFeatureInstanceDefault{}
|
||||
has, err := s.Where("feature = ?", feature.String()).Get(existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if has {
|
||||
existing.Enabled = enabled
|
||||
_, err = s.ID(existing.ID).Cols("enabled").Update(existing)
|
||||
return err
|
||||
}
|
||||
_, err = s.Insert(&ProFeatureInstanceDefault{Feature: feature.String(), Enabled: enabled})
|
||||
return err
|
||||
}
|
||||
|
||||
// ResetProFeatureInstanceDefault removes the instance-wide default for a
|
||||
// feature so the code default applies again.
|
||||
func ResetProFeatureInstanceDefault(s *xorm.Session, feature license.Feature) error {
|
||||
_, err := s.Where("feature = ?", feature.String()).Delete(&ProFeatureInstanceDefault{})
|
||||
return err
|
||||
}
|
||||
|
||||
// resolvePerUserProFeature resolves the per-user layer only (override →
|
||||
// instance default → code default). The caller must already have checked the
|
||||
// license layer.
|
||||
func resolvePerUserProFeature(s *xorm.Session, u *user.User, feature license.Feature) (bool, error) {
|
||||
if u != nil {
|
||||
if v, ok := u.ProFeatureOverrides[feature.String()]; ok {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
defaults, err := GetProFeatureInstanceDefaults(s)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if v, ok := defaults[feature.String()]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return license.PerUserDefault(feature), nil
|
||||
}
|
||||
|
||||
// IsProFeatureEnabledForUser returns whether a feature is effectively enabled
|
||||
// for the given user: the instance license must include it, and for per-user
|
||||
// toggleable features the user override / instance default / code default
|
||||
// chain must resolve to enabled. The user must carry its DB state — claim-
|
||||
// derived users miss ProFeatureOverrides.
|
||||
func IsProFeatureEnabledForUser(s *xorm.Session, u *user.User, feature license.Feature) (bool, error) {
|
||||
if !license.IsFeatureEnabled(feature) {
|
||||
return false, nil
|
||||
}
|
||||
if !license.IsPerUserToggleable(feature) {
|
||||
return true, nil
|
||||
}
|
||||
return resolvePerUserProFeature(s, u, feature)
|
||||
}
|
||||
|
||||
// IsProFeatureEnabledForAuth resolves the authenticated principal and checks
|
||||
// the feature for it. Link shares carry no per-user override, so only the
|
||||
// instance default / code default chain applies to them.
|
||||
func IsProFeatureEnabledForAuth(s *xorm.Session, a web.Auth, feature license.Feature) (bool, error) {
|
||||
if !license.IsFeatureEnabled(feature) {
|
||||
return false, nil
|
||||
}
|
||||
if !license.IsPerUserToggleable(feature) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var u *user.User
|
||||
if _, isUser := a.(*user.User); isUser {
|
||||
// Re-read from the DB: the auth user is claim-derived and does not
|
||||
// include ProFeatureOverrides.
|
||||
fresh, err := user.GetUserByID(s, a.GetID())
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
u = fresh
|
||||
}
|
||||
return resolvePerUserProFeature(s, u, feature)
|
||||
}
|
||||
|
||||
// EffectiveProFeaturesForUser returns the pro features effectively enabled for
|
||||
// the given user, for exposure to clients.
|
||||
func EffectiveProFeaturesForUser(s *xorm.Session, u *user.User) ([]license.Feature, error) {
|
||||
enabled := license.EnabledProFeatures()
|
||||
out := make([]license.Feature, 0, len(enabled))
|
||||
for _, f := range enabled {
|
||||
if license.IsPerUserToggleable(f) {
|
||||
on, err := resolvePerUserProFeature(s, u, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !on {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ProFeatureState describes one pro feature for the admin panel: its license
|
||||
// state and, for per-user toggleable features, the effective instance default.
|
||||
type ProFeatureState struct {
|
||||
Feature string `json:"feature" doc:"The feature key, e.g. time_tracking."`
|
||||
Licensed bool `json:"licensed" doc:"Whether the instance license includes this feature."`
|
||||
PerUserToggleable bool `json:"per_user_toggleable" doc:"Whether admins can grant or revoke this feature per user. Instance-wide features are always on for everyone when licensed."`
|
||||
DefaultEnabled bool `json:"default_enabled" doc:"The default for users without an override. Only meaningful for per-user toggleable features."`
|
||||
DefaultSource string `json:"default_source" enum:"code,instance" doc:"Where the default comes from: the built-in code default or an admin-set instance default."`
|
||||
}
|
||||
|
||||
// GetProFeatureStates returns the admin view of every known pro feature.
|
||||
func GetProFeatureStates(s *xorm.Session) ([]*ProFeatureState, error) {
|
||||
instanceDefaults, err := GetProFeatureInstanceDefaults(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
features := license.AllFeatures()
|
||||
out := make([]*ProFeatureState, 0, len(features))
|
||||
for _, f := range features {
|
||||
st := &ProFeatureState{
|
||||
Feature: f.String(),
|
||||
Licensed: license.IsFeatureEnabled(f),
|
||||
PerUserToggleable: license.IsPerUserToggleable(f),
|
||||
}
|
||||
if st.PerUserToggleable {
|
||||
st.DefaultEnabled = license.PerUserDefault(f)
|
||||
st.DefaultSource = "code"
|
||||
if v, ok := instanceDefaults[f.String()]; ok {
|
||||
st.DefaultEnabled = v
|
||||
st.DefaultSource = "instance"
|
||||
}
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UserProFeatureState describes one per-user toggleable feature for a single user.
|
||||
type UserProFeatureState struct {
|
||||
Feature string `json:"feature" doc:"The feature key, e.g. time_tracking."`
|
||||
Override *bool `json:"override" doc:"The admin-set override for this user, null when the instance default applies."`
|
||||
Effective bool `json:"effective" doc:"Whether the feature is effectively enabled for this user, license included."`
|
||||
}
|
||||
|
||||
// GetUserProFeatureStates returns the per-user toggleable features with the
|
||||
// user's override and effective state.
|
||||
func GetUserProFeatureStates(s *xorm.Session, u *user.User) ([]*UserProFeatureState, error) {
|
||||
out := []*UserProFeatureState{}
|
||||
for _, f := range license.AllFeatures() {
|
||||
if !license.IsPerUserToggleable(f) {
|
||||
continue
|
||||
}
|
||||
st := &UserProFeatureState{Feature: f.String()}
|
||||
if v, ok := u.ProFeatureOverrides[f.String()]; ok {
|
||||
override := v
|
||||
st.Override = &override
|
||||
}
|
||||
effective, err := IsProFeatureEnabledForUser(s, u, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.Effective = effective
|
||||
out = append(out, st)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SetUserProFeatureOverride sets or clears (enabled == nil) the per-user
|
||||
// override for a feature.
|
||||
func SetUserProFeatureOverride(s *xorm.Session, u *user.User, feature license.Feature, enabled *bool) error {
|
||||
if enabled == nil {
|
||||
delete(u.ProFeatureOverrides, feature.String())
|
||||
} else {
|
||||
if u.ProFeatureOverrides == nil {
|
||||
u.ProFeatureOverrides = map[string]bool{}
|
||||
}
|
||||
u.ProFeatureOverrides[feature.String()] = *enabled
|
||||
}
|
||||
_, err := s.Where("id = ?", u.ID).
|
||||
Cols("pro_feature_overrides").
|
||||
Update(u)
|
||||
return err
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/license"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsProFeatureEnabledForUser(t *testing.T) {
|
||||
u := &user.User{ID: 1}
|
||||
|
||||
t.Run("unlicensed instance disables the feature regardless of overrides", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
license.ResetForTests()
|
||||
|
||||
granted := &user.User{ID: 1, ProFeatureOverrides: map[string]bool{"time_tracking": true}}
|
||||
enabled, err := IsProFeatureEnabledForUser(s, granted, license.FeatureTimeTracking)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, enabled)
|
||||
})
|
||||
|
||||
t.Run("non-toggleable feature is on for everyone when licensed", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel})
|
||||
defer license.ResetForTests()
|
||||
|
||||
enabled, err := IsProFeatureEnabledForUser(s, u, license.FeatureAdminPanel)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, enabled)
|
||||
})
|
||||
|
||||
t.Run("code default applies without instance default or override", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
enabled, err := IsProFeatureEnabledForUser(s, u, license.FeatureTimeTracking)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, enabled, "time_tracking's code default is enabled")
|
||||
})
|
||||
|
||||
t.Run("instance default overrides the code default", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
require.NoError(t, SetProFeatureInstanceDefault(s, license.FeatureTimeTracking, false))
|
||||
|
||||
enabled, err := IsProFeatureEnabledForUser(s, u, license.FeatureTimeTracking)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, enabled)
|
||||
})
|
||||
|
||||
t.Run("user override wins over the instance default", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
require.NoError(t, SetProFeatureInstanceDefault(s, license.FeatureTimeTracking, false))
|
||||
granted := &user.User{ID: 1, ProFeatureOverrides: map[string]bool{"time_tracking": true}}
|
||||
|
||||
enabled, err := IsProFeatureEnabledForUser(s, granted, license.FeatureTimeTracking)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, enabled)
|
||||
})
|
||||
|
||||
t.Run("user override can revoke a default-enabled feature", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
revoked := &user.User{ID: 1, ProFeatureOverrides: map[string]bool{"time_tracking": false}}
|
||||
|
||||
enabled, err := IsProFeatureEnabledForUser(s, revoked, license.FeatureTimeTracking)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, enabled)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsProFeatureEnabledForAuth(t *testing.T) {
|
||||
t.Run("reads the user's override from the db", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
u, err := user.GetUserByID(s, 1)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, SetUserProFeatureOverride(s, u, license.FeatureTimeTracking, boolPtr(false)))
|
||||
|
||||
// A claim-derived auth user without the override field must still be revoked.
|
||||
enabled, err := IsProFeatureEnabledForAuth(s, &user.User{ID: 1}, license.FeatureTimeTracking)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, enabled)
|
||||
})
|
||||
|
||||
t.Run("link shares follow the instance default", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
enabled, err := IsProFeatureEnabledForAuth(s, &LinkSharing{ID: 1}, license.FeatureTimeTracking)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, enabled, "code default applies to link shares")
|
||||
|
||||
require.NoError(t, SetProFeatureInstanceDefault(s, license.FeatureTimeTracking, false))
|
||||
enabled, err = IsProFeatureEnabledForAuth(s, &LinkSharing{ID: 1}, license.FeatureTimeTracking)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, enabled)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetUserProFeatureOverride(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
u, err := user.GetUserByID(s, 1)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, SetUserProFeatureOverride(s, u, license.FeatureTimeTracking, boolPtr(false)))
|
||||
fresh, err := user.GetUserByID(s, 1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]bool{"time_tracking": false}, fresh.ProFeatureOverrides)
|
||||
|
||||
require.NoError(t, SetUserProFeatureOverride(s, fresh, license.FeatureTimeTracking, nil))
|
||||
fresh, err = user.GetUserByID(s, 1)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, fresh.ProFeatureOverrides)
|
||||
}
|
||||
|
||||
func TestSetProFeatureInstanceDefault(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
require.NoError(t, SetProFeatureInstanceDefault(s, license.FeatureTimeTracking, false))
|
||||
defaults, err := GetProFeatureInstanceDefaults(s)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]bool{"time_tracking": false}, defaults)
|
||||
|
||||
// Upsert: setting again must update, not duplicate.
|
||||
require.NoError(t, SetProFeatureInstanceDefault(s, license.FeatureTimeTracking, true))
|
||||
defaults, err = GetProFeatureInstanceDefaults(s)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]bool{"time_tracking": true}, defaults)
|
||||
|
||||
require.NoError(t, ResetProFeatureInstanceDefault(s, license.FeatureTimeTracking))
|
||||
defaults, err = GetProFeatureInstanceDefaults(s)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, defaults)
|
||||
}
|
||||
|
||||
func TestEffectiveProFeaturesForUser(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel, license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
revoked := &user.User{ID: 1, ProFeatureOverrides: map[string]bool{"time_tracking": false}}
|
||||
features, err := EffectiveProFeaturesForUser(s, revoked)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []license.Feature{license.FeatureAdminPanel}, features,
|
||||
"a revoked toggleable feature must drop out while instance-wide features stay")
|
||||
|
||||
granted := &user.User{ID: 1}
|
||||
features, err = EffectiveProFeaturesForUser(s, granted)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []license.Feature{license.FeatureAdminPanel, license.FeatureTimeTracking}, features)
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
|
@ -80,6 +80,7 @@ func SetupTests() {
|
|||
"totp",
|
||||
"oauth_codes",
|
||||
"notifications",
|
||||
"pro_feature_instance_defaults",
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
|
|
|||
|
|
@ -271,6 +271,16 @@ func (te *TimeEntry) stop(s *xorm.Session) (err error) {
|
|||
|
||||
// Returns the loaded entry rather than mutating te, so Update keeps its payload.
|
||||
func (te *TimeEntry) canDoTimeEntry(s *xorm.Session, a web.Auth, fetch bool) (*TimeEntry, bool, int, error) {
|
||||
// The route gate already 404s when time tracking is off for the user; this
|
||||
// guards non-route callers and keeps the permission on the model.
|
||||
enabled, err := IsProFeatureEnabledForAuth(s, a, license.FeatureTimeTracking)
|
||||
if err != nil {
|
||||
return nil, false, -1, err
|
||||
}
|
||||
if !enabled {
|
||||
return &TimeEntry{}, false, 0, nil
|
||||
}
|
||||
|
||||
entry := &TimeEntry{TaskID: te.TaskID, ProjectID: te.ProjectID}
|
||||
if fetch {
|
||||
var err error
|
||||
|
|
@ -397,12 +407,17 @@ func (te *TimeEntry) canModify(s *xorm.Session, a web.Auth) (bool, error) {
|
|||
// addTimeEntriesCountToTasks attaches each task's time-entry count for the
|
||||
// `time_entries_count` expand. Mirrors addCommentCountToTasks, but follows the
|
||||
// same gates as the time-entry endpoints: the count is left unset (absent) for
|
||||
// link shares or when the feature is unlicensed, so it can't leak that way.
|
||||
// link shares or when time tracking is off for the requesting user, so it
|
||||
// can't leak that way.
|
||||
func addTimeEntriesCountToTasks(s *xorm.Session, a web.Auth, taskIDs []int64, taskMap map[int64]*Task) error {
|
||||
if _, isShare := a.(*LinkSharing); isShare {
|
||||
return nil
|
||||
}
|
||||
if !license.IsFeatureEnabled(license.FeatureTimeTracking) {
|
||||
enabled, err := IsProFeatureEnabledForAuth(s, a, license.FeatureTimeTracking)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
if len(taskIDs) == 0 {
|
||||
|
|
|
|||
|
|
@ -39,6 +39,9 @@ func timePtr(t time.Time) *time.Time { return &t }
|
|||
// Entries: 1 = user1 on task 1, 2 = user1 on project 1, 3 = user3 on project 3.
|
||||
|
||||
func TestTimeEntry_CanRead(t *testing.T) {
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
entryID int64
|
||||
|
|
@ -73,6 +76,9 @@ func TestTimeEntry_CanRead(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestTimeEntry_CanCreate(t *testing.T) {
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
entry *TimeEntry
|
||||
|
|
@ -109,6 +115,9 @@ func TestTimeEntry_CanCreate(t *testing.T) {
|
|||
// Entry 3 is authored by user3; user1 can read project 3 but is not the author,
|
||||
// so it can read but not modify.
|
||||
func TestTimeEntry_CanModify(t *testing.T) {
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
entryID int64
|
||||
|
|
@ -360,6 +369,8 @@ func TestTimeEntry_RunningTimerEndTimeIsNull(t *testing.T) {
|
|||
|
||||
// Regression guard: the permission check must not clobber the update payload.
|
||||
func TestTimeEntry_Update(t *testing.T) {
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
db.LoadAndAssertFixtures(t)
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
|
@ -387,6 +398,9 @@ func TestTimeEntry_Update(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestTimeEntry_UpdateReassignsContainer(t *testing.T) {
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
validTimes := func(te *TimeEntry) {
|
||||
te.StartTime = time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)
|
||||
te.EndTime = timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC))
|
||||
|
|
@ -451,6 +465,9 @@ func TestTimeEntry_UpdateReassignsContainer(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestTimeEntry_UpdateReopenGuard(t *testing.T) {
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
a := &user.User{ID: 1}
|
||||
someStart := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
||||
|
|
@ -485,6 +502,9 @@ func TestTimeEntry_UpdateReopenGuard(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestTimeEntry_RejectsInvertedInterval(t *testing.T) {
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
a := &user.User{ID: 1}
|
||||
start := time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
before := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)
|
||||
|
|
@ -583,6 +603,9 @@ func TestTimeEntry_StopRunningTimer(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestTimeEntry_Events(t *testing.T) {
|
||||
license.SetForTests([]license.Feature{license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
u := &user.User{ID: 1}
|
||||
someStart := time.Date(2020, 1, 1, 9, 0, 0, 0, time.UTC)
|
||||
someEnd := timePtr(time.Date(2020, 1, 1, 10, 0, 0, 0, time.UTC))
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"code.vikunja.io/api/pkg/license"
|
||||
"code.vikunja.io/api/pkg/modules/auth/openid"
|
||||
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
|
@ -39,6 +40,11 @@ type UserWithSettings struct {
|
|||
IsLocalUser bool `json:"is_local_user"`
|
||||
AuthProvider string `json:"auth_provider"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
// The pro features effectively enabled for this user — the instance
|
||||
// license combined with per-user toggles. Unlike /info's
|
||||
// enabled_pro_features, this is the per-user truth clients should gate
|
||||
// UI on.
|
||||
EffectiveProFeatures []license.Feature `json:"effective_pro_features"`
|
||||
}
|
||||
|
||||
// UserShow gets all information about the current user
|
||||
|
|
@ -92,6 +98,11 @@ func UserShow(c *echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
us.EffectiveProFeatures, err = models.EffectiveProFeaturesForUser(s, u)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, us)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,246 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package apiv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/license"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
type proFeatureListBody struct {
|
||||
Body []*models.ProFeatureState
|
||||
}
|
||||
|
||||
type userProFeatureListBody struct {
|
||||
Body []*models.UserProFeatureState
|
||||
}
|
||||
|
||||
// Permissions are enforced by the gateV2AdminRoutes path middleware, not per-handler.
|
||||
func RegisterAdminProFeatureRoutes(api huma.API) {
|
||||
tags := []string{"admin"}
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "admin-pro-features-list",
|
||||
Summary: "List pro features (admin)",
|
||||
Description: "Returns every pro feature with its license state and, for per-user toggleable features, the instance-wide default and where it comes from. Restricted to instance admins on a licensed instance; unlicensed or non-admin callers get a 404.",
|
||||
Method: http.MethodGet,
|
||||
Path: "/admin/pro-features",
|
||||
Tags: tags,
|
||||
}, adminProFeaturesList)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "admin-pro-features-set-default",
|
||||
Summary: "Set a pro feature's instance default (admin)",
|
||||
Description: "Sets the instance-wide default for a per-user toggleable feature. Users without a per-user override follow this default. Only per-user toggleable features accept a default; others yield a 422.",
|
||||
Method: http.MethodPut,
|
||||
Path: "/admin/pro-features/{feature}",
|
||||
Tags: tags,
|
||||
}, adminProFeaturesSetDefault)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "admin-pro-features-reset-default",
|
||||
Summary: "Reset a pro feature's instance default (admin)",
|
||||
Description: "Removes the admin-set instance default so the built-in code default applies again.",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/admin/pro-features/{feature}",
|
||||
Tags: tags,
|
||||
}, adminProFeaturesResetDefault)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "admin-user-pro-features-list",
|
||||
Summary: "List a user's pro feature toggles (admin)",
|
||||
Description: "Returns the per-user toggleable pro features for the given user, with the admin-set override (null when the instance default applies) and the effective state including the license.",
|
||||
Method: http.MethodGet,
|
||||
Path: "/admin/users/{id}/pro-features",
|
||||
Tags: tags,
|
||||
}, adminUserProFeaturesList)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "admin-user-pro-features-set",
|
||||
Summary: "Set a user's pro feature override (admin)",
|
||||
Description: "Grants or revokes a per-user toggleable feature for the given user, overriding the instance default. Only per-user toggleable features accept an override; others yield a 422.",
|
||||
Method: http.MethodPut,
|
||||
Path: "/admin/users/{id}/pro-features/{feature}",
|
||||
Tags: tags,
|
||||
}, adminUserProFeaturesSet)
|
||||
|
||||
Register(api, huma.Operation{
|
||||
OperationID: "admin-user-pro-features-clear",
|
||||
Summary: "Clear a user's pro feature override (admin)",
|
||||
Description: "Removes the per-user override so the instance default applies to this user again. Returns the user's refreshed toggle list.",
|
||||
Method: http.MethodDelete,
|
||||
Path: "/admin/users/{id}/pro-features/{feature}",
|
||||
// Override the wrapper's DELETE→204: the refreshed toggle list is returned.
|
||||
DefaultStatus: http.StatusOK,
|
||||
Tags: tags,
|
||||
}, adminUserProFeaturesClear)
|
||||
}
|
||||
|
||||
func init() { AddRouteRegistrar(RegisterAdminProFeatureRoutes) }
|
||||
|
||||
// perUserToggleableFeatureFromPath parses the {feature} path param and rejects
|
||||
// features that cannot be managed per user.
|
||||
func perUserToggleableFeatureFromPath(featureKey string) (license.Feature, error) {
|
||||
f, ok := license.FeatureFromString(featureKey)
|
||||
if !ok {
|
||||
return license.FeatureUnknown, huma.Error404NotFound("unknown feature " + featureKey)
|
||||
}
|
||||
if !license.IsPerUserToggleable(f) {
|
||||
return license.FeatureUnknown, huma.Error422UnprocessableEntity("feature " + featureKey + " is instance-wide and cannot be toggled per user")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func adminProFeaturesList(_ context.Context, _ *struct{}) (*proFeatureListBody, error) {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
states, err := models.GetProFeatureStates(s)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &proFeatureListBody{Body: states}, nil
|
||||
}
|
||||
|
||||
func adminProFeaturesSetDefault(_ context.Context, in *struct {
|
||||
Feature string `path:"feature" doc:"The feature key, e.g. time_tracking."`
|
||||
Body struct {
|
||||
DefaultEnabled bool `json:"default_enabled" doc:"Whether the feature should be enabled for users without a per-user override."`
|
||||
}
|
||||
}) (*proFeatureListBody, error) {
|
||||
f, err := perUserToggleableFeatureFromPath(in.Feature)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return commitProFeatureChange(func(s *xorm.Session) error {
|
||||
return models.SetProFeatureInstanceDefault(s, f, in.Body.DefaultEnabled)
|
||||
})
|
||||
}
|
||||
|
||||
func adminProFeaturesResetDefault(_ context.Context, in *struct {
|
||||
Feature string `path:"feature" doc:"The feature key, e.g. time_tracking."`
|
||||
}) (*emptyBody, error) {
|
||||
f, err := perUserToggleableFeatureFromPath(in.Feature)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
if err := models.ResetProFeatureInstanceDefault(s, f); err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
if err := s.Commit(); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &emptyBody{}, nil
|
||||
}
|
||||
|
||||
// commitProFeatureChange runs a write and returns the fresh feature list, the
|
||||
// response shape both default-changing endpoints share.
|
||||
func commitProFeatureChange(change func(s *xorm.Session) error) (*proFeatureListBody, error) {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
if err := change(s); err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
if err := s.Commit(); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
states, err := models.GetProFeatureStates(s)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &proFeatureListBody{Body: states}, nil
|
||||
}
|
||||
|
||||
func adminUserProFeaturesList(_ context.Context, in *struct {
|
||||
ID int64 `path:"id" doc:"The user id."`
|
||||
}) (*userProFeatureListBody, error) {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
u, err := user.GetUserByID(s, in.ID)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
states, err := models.GetUserProFeatureStates(s, u)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &userProFeatureListBody{Body: states}, nil
|
||||
}
|
||||
|
||||
func adminUserProFeaturesSet(_ context.Context, in *struct {
|
||||
ID int64 `path:"id" doc:"The user id."`
|
||||
Feature string `path:"feature" doc:"The feature key, e.g. time_tracking."`
|
||||
Body struct {
|
||||
Enabled bool `json:"enabled" doc:"Whether the feature should be enabled for this user, regardless of the instance default."`
|
||||
}
|
||||
}) (*userProFeatureListBody, error) {
|
||||
enabled := in.Body.Enabled
|
||||
return updateUserProFeatureOverride(in.ID, in.Feature, &enabled)
|
||||
}
|
||||
|
||||
func adminUserProFeaturesClear(_ context.Context, in *struct {
|
||||
ID int64 `path:"id" doc:"The user id."`
|
||||
Feature string `path:"feature" doc:"The feature key, e.g. time_tracking."`
|
||||
}) (*userProFeatureListBody, error) {
|
||||
return updateUserProFeatureOverride(in.ID, in.Feature, nil)
|
||||
}
|
||||
|
||||
func updateUserProFeatureOverride(userID int64, featureKey string, enabled *bool) (*userProFeatureListBody, error) {
|
||||
f, err := perUserToggleableFeatureFromPath(featureKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
u, err := user.GetUserByID(s, userID)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
if err := models.SetUserProFeatureOverride(s, u, f, enabled); err != nil {
|
||||
_ = s.Rollback()
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
if err := s.Commit(); err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
|
||||
states, err := models.GetUserProFeatureStates(s, u)
|
||||
if err != nil {
|
||||
return nil, translateDomainError(err)
|
||||
}
|
||||
return &userProFeatureListBody{Body: states}, nil
|
||||
}
|
||||
|
|
@ -24,21 +24,47 @@ import (
|
|||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/events"
|
||||
"code.vikunja.io/api/pkg/license"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/modules/auth"
|
||||
"code.vikunja.io/api/pkg/web/handler"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
"github.com/danielgtaylor/huma/v2/conditional"
|
||||
)
|
||||
|
||||
// timeTrackingGate is Huma operation middleware that 404s a time-tracking op when the license
|
||||
// feature is off. It's a middleware because license state can change while the instance is running.
|
||||
// timeTrackingGate is Huma operation middleware that 404s a time-tracking op when time
|
||||
// tracking is off for the requesting user — via the license or a per-user toggle. It's a
|
||||
// middleware because license state and toggles can change while the instance is running.
|
||||
func timeTrackingGate(api huma.API) func(huma.Context, func(huma.Context)) {
|
||||
return func(ctx huma.Context, next func(huma.Context)) {
|
||||
if !license.IsFeatureEnabled(license.FeatureTimeTracking) {
|
||||
_ = huma.WriteErr(api, ctx, http.StatusNotFound, "Not Found")
|
||||
return
|
||||
}
|
||||
|
||||
// The token middleware authenticates before the gate, so missing auth
|
||||
// here is a programming error, not a client one.
|
||||
a, err := auth.GetAuthFromContext(ctx.Context())
|
||||
if err != nil {
|
||||
log.Errorf("v2: could not resolve auth in time tracking gate: %s", err)
|
||||
_ = huma.WriteErr(api, ctx, http.StatusNotFound, "Not Found")
|
||||
return
|
||||
}
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
enabled, err := models.IsProFeatureEnabledForAuth(s, a, license.FeatureTimeTracking)
|
||||
if err != nil {
|
||||
log.Errorf("v2: could not check time tracking toggle: %s", err)
|
||||
_ = huma.WriteErr(api, ctx, http.StatusInternalServerError, "Internal Server Error")
|
||||
return
|
||||
}
|
||||
if !enabled {
|
||||
_ = huma.WriteErr(api, ctx, http.StatusNotFound, "Not Found")
|
||||
return
|
||||
}
|
||||
next(ctx)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,11 @@ type User struct {
|
|||
FrontendSettings interface{} `xorm:"json null" json:"-"`
|
||||
ExtraSettingsLinks map[string]any `xorm:"json null" json:"-"`
|
||||
|
||||
// ProFeatureOverrides holds per-user admin-set overrides for per-user
|
||||
// toggleable pro features, keyed by feature string ("time_tracking").
|
||||
// A missing key means the instance default applies.
|
||||
ProFeatureOverrides map[string]bool `xorm:"json null" json:"-"`
|
||||
|
||||
ExportFileID int64 `xorm:"bigint null" json:"-"`
|
||||
|
||||
// A timestamp when this task was created. You cannot change this value.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,264 @@
|
|||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-present Vikunja and contributors. All rights reserved.
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package webtests
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/license"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type proFeatureStateBody struct {
|
||||
Feature string `json:"feature"`
|
||||
Licensed bool `json:"licensed"`
|
||||
PerUserToggleable bool `json:"per_user_toggleable"`
|
||||
DefaultEnabled bool `json:"default_enabled"`
|
||||
DefaultSource string `json:"default_source"`
|
||||
}
|
||||
|
||||
type userProFeatureStateBody struct {
|
||||
Feature string `json:"feature"`
|
||||
Override *bool `json:"override"`
|
||||
Effective bool `json:"effective"`
|
||||
}
|
||||
|
||||
func findProFeature(t *testing.T, states []proFeatureStateBody, feature string) proFeatureStateBody {
|
||||
t.Helper()
|
||||
for _, st := range states {
|
||||
if st.Feature == feature {
|
||||
return st
|
||||
}
|
||||
}
|
||||
t.Fatalf("feature %s not in response: %v", feature, states)
|
||||
return proFeatureStateBody{}
|
||||
}
|
||||
|
||||
func TestHumaAdminProFeatures(t *testing.T) {
|
||||
t.Run("non-admin user gets 404", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel})
|
||||
defer license.ResetForTests()
|
||||
|
||||
res := adminReq(t, e, http.MethodGet, "/api/v2/admin/pro-features", &testuser1, "")
|
||||
assert.Equal(t, http.StatusNotFound, res.Code)
|
||||
})
|
||||
|
||||
t.Run("list reports license state, toggleability and defaults", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel, license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
admin := promoteToAdmin(t, 1)
|
||||
|
||||
res := adminReq(t, e, http.MethodGet, "/api/v2/admin/pro-features", admin, "")
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
|
||||
var states []proFeatureStateBody
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &states))
|
||||
|
||||
tt := findProFeature(t, states, "time_tracking")
|
||||
assert.True(t, tt.Licensed)
|
||||
assert.True(t, tt.PerUserToggleable)
|
||||
assert.True(t, tt.DefaultEnabled)
|
||||
assert.Equal(t, "code", tt.DefaultSource)
|
||||
|
||||
ap := findProFeature(t, states, "admin_panel")
|
||||
assert.True(t, ap.Licensed)
|
||||
assert.False(t, ap.PerUserToggleable)
|
||||
})
|
||||
|
||||
t.Run("setting and resetting the instance default", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel, license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
admin := promoteToAdmin(t, 1)
|
||||
|
||||
res := adminReq(t, e, http.MethodPut, "/api/v2/admin/pro-features/time_tracking", admin, `{"default_enabled": false}`)
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
|
||||
var states []proFeatureStateBody
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &states))
|
||||
tt := findProFeature(t, states, "time_tracking")
|
||||
assert.False(t, tt.DefaultEnabled)
|
||||
assert.Equal(t, "instance", tt.DefaultSource)
|
||||
|
||||
res = adminReq(t, e, http.MethodDelete, "/api/v2/admin/pro-features/time_tracking", admin, "")
|
||||
require.Equal(t, http.StatusNoContent, res.Code, res.Body.String())
|
||||
|
||||
res = adminReq(t, e, http.MethodGet, "/api/v2/admin/pro-features", admin, "")
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &states))
|
||||
tt = findProFeature(t, states, "time_tracking")
|
||||
assert.True(t, tt.DefaultEnabled)
|
||||
assert.Equal(t, "code", tt.DefaultSource)
|
||||
})
|
||||
|
||||
t.Run("an instance-wide feature rejects a default", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel})
|
||||
defer license.ResetForTests()
|
||||
|
||||
admin := promoteToAdmin(t, 1)
|
||||
|
||||
res := adminReq(t, e, http.MethodPut, "/api/v2/admin/pro-features/admin_panel", admin, `{"default_enabled": false}`)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, res.Code, res.Body.String())
|
||||
})
|
||||
|
||||
t.Run("an unknown feature is a 404", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel})
|
||||
defer license.ResetForTests()
|
||||
|
||||
admin := promoteToAdmin(t, 1)
|
||||
|
||||
res := adminReq(t, e, http.MethodPut, "/api/v2/admin/pro-features/nope", admin, `{"default_enabled": false}`)
|
||||
assert.Equal(t, http.StatusNotFound, res.Code, res.Body.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestHumaAdminUserProFeatures(t *testing.T) {
|
||||
t.Run("set, list and clear an override", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel, license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
admin := promoteToAdmin(t, 1)
|
||||
|
||||
// Revoke for user 2.
|
||||
res := adminReq(t, e, http.MethodPut, "/api/v2/admin/users/2/pro-features/time_tracking", admin, `{"enabled": false}`)
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
|
||||
var states []userProFeatureStateBody
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &states))
|
||||
require.Len(t, states, 1)
|
||||
assert.Equal(t, "time_tracking", states[0].Feature)
|
||||
require.NotNil(t, states[0].Override)
|
||||
assert.False(t, *states[0].Override)
|
||||
assert.False(t, states[0].Effective)
|
||||
|
||||
res = adminReq(t, e, http.MethodGet, "/api/v2/admin/users/2/pro-features", admin, "")
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &states))
|
||||
require.Len(t, states, 1)
|
||||
require.NotNil(t, states[0].Override)
|
||||
assert.False(t, *states[0].Override)
|
||||
|
||||
// Clearing the override falls back to the (code) default: enabled.
|
||||
res = adminReq(t, e, http.MethodDelete, "/api/v2/admin/users/2/pro-features/time_tracking", admin, "")
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &states))
|
||||
require.Len(t, states, 1)
|
||||
assert.Nil(t, states[0].Override)
|
||||
assert.True(t, states[0].Effective)
|
||||
})
|
||||
|
||||
t.Run("an override wins over the instance default", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel, license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
admin := promoteToAdmin(t, 1)
|
||||
|
||||
res := adminReq(t, e, http.MethodPut, "/api/v2/admin/pro-features/time_tracking", admin, `{"default_enabled": false}`)
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
|
||||
res = adminReq(t, e, http.MethodPut, "/api/v2/admin/users/2/pro-features/time_tracking", admin, `{"enabled": true}`)
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
|
||||
var states []userProFeatureStateBody
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &states))
|
||||
require.Len(t, states, 1)
|
||||
assert.True(t, states[0].Effective)
|
||||
})
|
||||
|
||||
t.Run("a missing user is a 404", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel, license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
admin := promoteToAdmin(t, 1)
|
||||
|
||||
res := adminReq(t, e, http.MethodGet, "/api/v2/admin/users/9999/pro-features", admin, "")
|
||||
assert.Equal(t, http.StatusNotFound, res.Code, res.Body.String())
|
||||
})
|
||||
|
||||
t.Run("non-admin user gets 404", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel, license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
res := adminReq(t, e, http.MethodPut, "/api/v2/admin/users/2/pro-features/time_tracking", &testuser1, `{"enabled": false}`)
|
||||
assert.Equal(t, http.StatusNotFound, res.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// The per-user gate: a revoked user gets 404s on every time-tracking route
|
||||
// while other users keep access.
|
||||
func TestHumaTimeEntry_PerUserGate(t *testing.T) {
|
||||
t.Run("revoked user is gated, others are not", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel, license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
admin := promoteToAdmin(t, 6)
|
||||
|
||||
res := adminReq(t, e, http.MethodPut, "/api/v2/admin/users/1/pro-features/time_tracking", admin, `{"enabled": false}`)
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries", "", humaTokenFor(t, &testuser1), "")
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code, "revoked user must get a 404")
|
||||
|
||||
rec = humaRequest(t, e, http.MethodGet, "/api/v2/time-entries", "", humaTokenFor(t, &testuser2), "")
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "other users keep access: %s", rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("disabled instance default gates everyone but granted users", func(t *testing.T) {
|
||||
e, err := setupTestEnv()
|
||||
require.NoError(t, err)
|
||||
license.SetForTests([]license.Feature{license.FeatureAdminPanel, license.FeatureTimeTracking})
|
||||
defer license.ResetForTests()
|
||||
|
||||
admin := promoteToAdmin(t, 6)
|
||||
|
||||
res := adminReq(t, e, http.MethodPut, "/api/v2/admin/pro-features/time_tracking", admin, `{"default_enabled": false}`)
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
res = adminReq(t, e, http.MethodPut, "/api/v2/admin/users/1/pro-features/time_tracking", admin, `{"enabled": true}`)
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
|
||||
rec := humaRequest(t, e, http.MethodGet, "/api/v2/time-entries", "", humaTokenFor(t, &testuser1), "")
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "granted user keeps access: %s", rec.Body.String())
|
||||
|
||||
rec = humaRequest(t, e, http.MethodGet, "/api/v2/time-entries", "", humaTokenFor(t, &testuser2), "")
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code, "non-granted user must get a 404")
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue