Skip to content

Commit

Permalink
feat: ✨ gestion de la config console (System Settings)
Browse files Browse the repository at this point in the history
  • Loading branch information
KomsteRr authored and this-is-tobi committed Sep 24, 2024
1 parent 9dfb13f commit 9f97820
Show file tree
Hide file tree
Showing 12 changed files with 259 additions and 50 deletions.
12 changes: 9 additions & 3 deletions apps/client/src/App.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { SystemSettings } from '@cpn-console/shared'
import { apiPrefix } from '@cpn-console/shared'
import { getKeycloak } from './utils/keycloak/keycloak.js'
import { useSnackbarStore } from './stores/snackbar.js'
Expand Down Expand Up @@ -38,21 +39,26 @@ watch(label, (label: string) => {
quickLinks.value[0].label = label
})
const systemSettings = ref<SystemSettings>()
const serviceStore = useServiceStore()
onBeforeMount(() => {
serviceStore.startHealthPolling()
serviceStore.checkServicesHealth()
systemStore.listSystemSettings()
systemSettings.value = systemStore.systemSettings
})
</script>

<template>
<DsfrHeader
service-title="Console Cloud π Native"
:logo-text="['Ministère', 'de l’intérieur', 'et des outre-mer']"
:service-title="systemSettings?.appName ?? 'Console Cloud π Native'"
:logo-text="systemSettings?.appSubTitle ?? ['Ministère', 'de l’intérieur', 'et des outre-mer']"
:quick-links="quickLinks"
/>
<DsfrNotice
v-if="systemStore.systemSettingsByKey.maintenance?.value === 'on'"
v-if="systemStore.systemSettings?.maintenance === 'on'"
title="Le mode Maintenance est actuellement activé"
data-testid="maintenance-notice"
/>
Expand Down
2 changes: 1 addition & 1 deletion apps/client/src/router/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ router.beforeEach(async (to, _from, next) => {
&& userStore.isLoggedIn
) {
await systemStore.listSystemSettings('maintenance')
if (systemStore.systemSettingsByKey.maintenance?.value === 'on' && userStore.adminPerms === 0n) return next('/maintenance')
if (systemStore.systemSettings?.maintenance === 'on' && userStore.adminPerms === 0n) return next('/maintenance')
}

next()
Expand Down
31 changes: 13 additions & 18 deletions apps/client/src/stores/system-settings.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,29 @@
import { defineStore } from 'pinia'
import {
type SystemSetting,
type SystemSettings,
type UpsertSystemSettingBody,
resourceListToDictByKey,
import type {
SystemSettings,
} from '@cpn-console/shared'
import { apiClient, extractData } from '@/api/xhr-client.js'

export const useSystemSettingsStore = defineStore('systemSettings', () => {
const systemSettings = ref<SystemSettings>([])
const systemSettingsByKey = computed(() => resourceListToDictByKey(systemSettings.value))
const systemSettings = ref<SystemSettings>()

const listSystemSettings = async (key?: SystemSetting['key']) => {
const listSystemSettings = async (key?: keyof SystemSettings) => {
systemSettings.value = await apiClient.SystemSettings.listSystemSettings({ query: { key } })
.then(response => extractData(response, 200))
}

const upsertSystemSetting = async (newSystemSetting: UpsertSystemSettingBody) => {
const res = await apiClient.SystemSettings.upsertSystemSetting({ body: newSystemSetting })
.then(response => extractData(response, 201))
systemSettings.value = systemSettings.value
.toSpliced(systemSettings.value
.findIndex(systemSetting => systemSetting.key === res.key), 1, res)
return res
}
// const upsertSystemSetting = async (newSystemSetting: UpsertSystemSettingBody) => {
// const res = await apiClient.SystemSettings.upsertSystemSetting({ body: newSystemSetting })
// .then(response => extractData(response, 201))
// systemSettings.value = systemSettings.value
// .toSpliced(systemSettings.value
// .findIndex(systemSetting => systemSetting.key === res.key), 1, res)
// return res
// }

return {
systemSettings,
systemSettingsByKey,
listSystemSettings,
upsertSystemSetting,
// upsertSystemSetting,
}
})
74 changes: 65 additions & 9 deletions apps/client/src/views/admin/SystemSettings.vue
Original file line number Diff line number Diff line change
@@ -1,29 +1,85 @@
<script lang="ts" setup>
import type { SystemSettings } from '@cpn-console/shared'
import { useSystemSettingsStore } from '@/stores/system-settings.js'
const systemStore = useSystemSettingsStore()
onBeforeMount(async () => {
const updated = ref<Record<string, Record<string, string>>>({})
// permet de definir quel input choisir ??
// function refTheValues(settings: SystemSetting[]) {
// return settings.map((setting) => {
// return {
// ...setting,
// // manifest ??
// }
// })
// }
const systemSettings = ref<SystemSettings>()
// reload les settings
async function reload() {
await systemStore.listSystemSettings()
systemSettings.value = systemStore.systemSettings
updated.value = {}
}
onBeforeMount(async () => {
// await systemStore.listSystemSettings()
reload()
})
// A modifié pour save les settings dynamiquement
async function upsertSystemSetting(key: string, value: boolean) {
await systemStore.upsertSystemSetting({ key, value: value ? 'on' : 'off' })
// await systemStore.upsertSystemSetting({ key, value: value ? 'on' : 'off' })
console.log(key + value)
}
</script>

<template>
<h1>Réglages de la console Cloud π Native</h1>
<div
class="flex <md:flex-col-reverse items-center justify-between gap-2 mt-8"
class="flex <md:flex-col items-center justify-between gap-2 mt-8"
>
<!-- {{ systemSettings }} -->
<DsfrToggleSwitch
v-for="setting in systemStore.systemSettings"
:key="setting.key"
:model-value="setting.value === 'on'"
:label="`${setting.value === 'on' ? 'Désactiver' : 'Activer'} le mode ${setting.key}`"
:data-testid="`toggle-${setting.key}`"
@update:model-value="(event: boolean) => upsertSystemSetting(setting.key, event)"
:model-value="systemSettings.maintenance === 'on'"
:label="`${systemSettings.maintenance === 'on' ? 'Désactiver' : 'Activer'} le mode maintenance`"
data-testid="toggle-maintenance"
@update:model-value="(event: boolean) => upsertSystemSetting('maintenance', event)"
/>
<DsfrInput
v-model="systemSettings.appName"
data-testid="input-appName"
label="appName"
label-visible
/>
<DsfrInput
v-model="systemSettings.contactMail"
data-testid="input-contactMail"
label="contactMail"
label-visible
/>
<DsfrInput
v-model="systemSettings.appSubTitle"
data-testid="input-appSubTitle"
label="appSubTitle"
label-visible
/>
</div>
</template>

<!-- <template>
<h1>Réglages de la console Cloud π Native</h1>
<div
v-if="!systemStore.systemSettings.length"
class="flex <md:flex-col-reverse items-center justify-between gap-2 mt-8"
>
<div> -->
<!-- trouvé comment faire du l'input dynamique -->
<!-- HINT : etablir des regle, ex : on|off => switch, sinon input classic -->
<!-- </div>
</div>
</template> -->
3 changes: 2 additions & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,8 @@
"node-vault-client": "^1.0.1",
"prisma": "^5.19.1",
"undici": "^6.19.2",
"vitest-mock-extended": "^1.3.1"
"vitest-mock-extended": "^1.3.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@cpn-console/eslint-config": "workspace:^",
Expand Down
19 changes: 12 additions & 7 deletions apps/server/src/resources/system/settings/business.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { UpsertSystemSettingBody } from '@cpn-console/shared'
import {
getSystemSettings as getSystemSettingsQuery,
upsertSystemSetting as upsertSystemSettingQuery,
} from './queries.js'
import type { UpsertSystemSettingsBody } from '@cpn-console/shared'
import { upsertSystemSetting as upsertSystemSettingQuery } from './queries.js'

export const getSystemSettings = (key?: string) => getSystemSettingsQuery({ key })
import { config } from '@/utils/config.js'

export const upsertSystemSetting = (newSystemSetting: UpsertSystemSettingBody) => upsertSystemSettingQuery(newSystemSetting)
export function getSystemSettings(key?: keyof typeof config) {
if (key) {
return { [key]: config[key] }
} else {
return config
}
}

export const upsertSystemSettings = (newSystemSetting: UpsertSystemSettingsBody) => upsertSystemSettingQuery(newSystemSetting)
10 changes: 8 additions & 2 deletions apps/server/src/resources/system/settings/router.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AdminAuthorized, systemSettingsContract } from '@cpn-console/shared'
import { getSystemSettings, upsertSystemSetting } from './business.js'
import { getSystemSettings, upsertSystemSettings } from './business.js'
import { serverInstance } from '@/app.js'
import { authUser } from '@/utils/controller.js'
import { Forbidden403 } from '@/utils/errors.js'
Expand All @@ -9,6 +9,12 @@ export function systemSettingsRouter() {
listSystemSettings: async ({ query }) => {
const systemSettings = await getSystemSettings(query.key)

if (!systemSettings) {
return {
status: 500,
body: { error: 'System settings not found' },
}
}
return {
status: 200,
body: systemSettings,
Expand All @@ -19,7 +25,7 @@ export function systemSettingsRouter() {
const perms = await authUser(req)
if (!AdminAuthorized.isAdmin(perms.adminPermissions)) return new Forbidden403()

const systemSetting = await upsertSystemSetting(data)
const systemSetting = await upsertSystemSettings(data)

return {
status: 201,
Expand Down
133 changes: 133 additions & 0 deletions apps/server/src/utils/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import path from 'node:path'
import { SystemSettingSchema as ConfigSchema } from '@cpn-console/shared'

const getNodeEnv: () => 'development' | 'test' | 'production' = () => {
if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') {
return process.env.NODE_ENV
}
return 'production'
}

function snakeCaseToCamelCase(input: string) {
return input
.split('_')
.reduce((acc, cur, i) => {
if (!i) {
return cur.toLowerCase()
}
return acc + cur.charAt(0).toUpperCase() + cur.substring(1).toLowerCase()
}, '')
}

function deepMerge(target: any, source: any) {
const result = { ...target, ...source }
for (const key of Object.keys(result)) {
if (Array.isArray(target[key]) && Array.isArray(source[key])) {
result[key] = result[key].map((value: unknown, idx: number) => {
return typeof value === 'object'
? deepMerge(target[key][idx], source[key][idx])
: structuredClone(result[key][idx])
})
} else if (typeof target[key] === 'object' && typeof source[key] === 'object') {
result[key] = deepMerge(target[key], source[key])
} else {
result[key] = structuredClone(result[key])
}
}
return result
}

const configPaths = {
development: path.resolve(__dirname, '../../config-example.json'),
production: '/app/config.json',
test: path.resolve(__dirname, './configs/config.valid.spec.json'),
}

const CONFIG_PATH = configPaths[getNodeEnv()]
const ENV_PREFIX = ['API__', 'DOC__']

// export const ConfigSchema = z.object({
// maintenance: z.string().default('off'),
// appName: z.string().default('Console Cloud Pi Native TEST DE FOU'),
// contactMail: z.string().default('[email protected]'),
// appSubTitle: z.array(z.string()).default(['Ministère 2', 'de l’intérieur 3', 'et des outre-mer 4']),
// // appLogoUrl: z.string().default(''), // pas sur de la faisabilité
// }).strict()

export type Config = Zod.infer<typeof ConfigSchema>

// maybe a modifié ? ?
export function parseEnv(obj: Record<string, string>): Config | Record<PropertyKey, never> {
return Object
.entries(obj)
.map(([key, value]) => key
.split('__')
.toReversed()
.reduce((acc, val, idx) => {
if (!idx) {
try {
return { [snakeCaseToCamelCase(val)]: JSON.parse(value) }
} catch (_e) {
return { [snakeCaseToCamelCase(val)]: value }
}
} else {
return { [snakeCaseToCamelCase(val)]: acc }
}
}, {}))
.reduce((acc, val) => deepMerge(acc, val), {})
}

// pour recup l'env
export function getEnv(prefix: string | string[] = ENV_PREFIX): Record<string, string> {
return Object
.entries(process.env)
.filter(([key, _value]) => Array.isArray(prefix) ? prefix.some(p => key.startsWith(p)) : key.startsWith(prefix))
.reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {})
}

export async function getConfig(opts?: { fileConfigPath?: string, envPrefix?: string | string[] }) {
const fileConfigPath = opts?.fileConfigPath ?? CONFIG_PATH
const envPrefix = opts?.envPrefix ?? ENV_PREFIX

const defaultConfig = ConfigSchema.parse({})
let envConfig: Config | Record<PropertyKey, never> = {}
let fileConfig: Config | Record<PropertyKey, never> = {}
// const dbConfig: Config | Record<PropertyKey, never> = {}

try {
envConfig = parseEnv(getEnv(envPrefix))
ConfigSchema.partial().parse(envConfig)
} catch (error) {
const errorMessage = { description: 'invalid config environment variables', error }
throw new Error(JSON.stringify(errorMessage))
}

try {
const file = await import(fileConfigPath, { assert: { type: 'json' } })

.catch(_e => console.log(`no config file detected "${fileConfigPath}"`))
if (file) {
fileConfig = file.default
ConfigSchema.partial().parse(fileConfig)
}
} catch (error) {
const errorMessage = { description: `invalid config file "${fileConfigPath}"`, error }
throw new Error(JSON.stringify(errorMessage))
}

// try {
// dbConfig = JSON.parse(await getSystemSettings())
// } catch (error) {
// const errorMessage = { description: `invalid config env`, error }
// throw new Error(JSON.stringify(errorMessage))
// }

return {
...defaultConfig,
...fileConfig,
...envConfig,
// ...dbConfig,
}
}

export const config = await getConfig()
Loading

0 comments on commit 9f97820

Please sign in to comment.