This commit is contained in:
2026-04-05 01:30:00 +02:00
parent 5ac3847b5e
commit d48f551c66
10 changed files with 558 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
import type {IApiClient} from './client';
import {getPlatform} from '../platform';
import {useAuthStore} from '../stores/auth';
import type {
CategoryResponse,
ChannelResponse,
ClaimAdminRequest,
CreateCategoryRequest,
CreateChannelRequest,
CreateMessageRequest,
CreateServerRequest,
LoginRequest,
LoginResponse,
MessageResponse,
ServerResponse,
SshChallengeResponse,
UserResponse,
} from './types';
/**
* Unified API Client for OxSpeak.
* Uses fetch() directly in all environments.
* Uses the Auth store for token persistence and state.
*/
export class ApiClient implements IApiClient {
private token: string | null = null;
private tokenLoaded = false;
constructor(public readonly baseUrl: string) {
}
private async ensureTokenLoaded() {
if (!this.tokenLoaded) {
try {
const authStore = useAuthStore();
this.token = await authStore.loadToken(this.baseUrl);
} catch (e) {
// Fallback to platform directly if store is not available
this.token = await getPlatform().loadToken(this.baseUrl);
}
this.tokenLoaded = true;
}
}
private async request<T>(method: string, path: string, body?: any): Promise<T> {
await this.ensureTokenLoaded();
const cleanBaseUrl = this.baseUrl.endsWith('/') ? this.baseUrl.slice(0, -1) : this.baseUrl;
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
const url = `${cleanBaseUrl}/${cleanPath}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const response = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
let errorData;
try {
errorData = await response.json();
} catch (e) {
errorData = {message: await response.text()};
}
throw new Error(errorData.message || `Erreur HTTP ${response.status}`);
}
if (response.status === 204) {
return {} as T;
}
return await response.json();
}
private async setToken(token: string) {
this.token = token;
this.tokenLoaded = true;
try {
const authStore = useAuthStore();
await authStore.saveToken(this.baseUrl, token);
} catch (e) {
// Fallback to platform directly
await getPlatform().saveToken(this.baseUrl, token);
}
}
// AUTH
async login(req: LoginRequest): Promise<LoginResponse> {
const res = await this.request<LoginResponse>('POST', 'api/auth/login', req);
await this.setToken(res.token);
return res;
}
async verifyToken(): Promise<UserResponse> {
return await this.request<UserResponse>('GET', 'api/auth/me');
}
async claimAdmin(req: ClaimAdminRequest): Promise<void> {
return await this.request<void>('POST', 'api/auth/claim-admin', req);
}
async sshChallenge(): Promise<SshChallengeResponse> {
return await this.request<SshChallengeResponse>('POST', 'api/auth/ssh-challenge');
}
// SERVER
async getServerList(): Promise<ServerResponse[]> {
return await this.request<ServerResponse[]>('GET', 'api/server');
}
async createServer(req: CreateServerRequest): Promise<ServerResponse> {
return await this.request<ServerResponse>('POST', 'api/server', req);
}
async getServerTree(serverId: string): Promise<any> {
return await this.request<any>('GET', `api/server/servers/${serverId}/tree/`);
}
// CATEGORY
async getCategoryList(serverId?: string): Promise<CategoryResponse[]> {
const path = serverId ? `api/category?server_id=${serverId}` : 'api/category';
return await this.request<CategoryResponse[]>('GET', path);
}
async createCategory(req: CreateCategoryRequest): Promise<CategoryResponse> {
return await this.request<CategoryResponse>('POST', 'api/category', req);
}
// CHANNEL
async getChannelList(): Promise<ChannelResponse[]> {
return await this.request<ChannelResponse[]>('GET', 'api/channel');
}
async createChannel(req: CreateChannelRequest): Promise<ChannelResponse> {
return await this.request<ChannelResponse>('POST', 'api/channel', req);
}
// MESSAGE
async getMessageList(): Promise<MessageResponse[]> {
return await this.request<MessageResponse[]>('GET', 'api/message');
}
async createMessage(req: CreateMessageRequest): Promise<MessageResponse> {
return await this.request<MessageResponse>('POST', 'api/message', req);
}
// USER
async getUserList(): Promise<UserResponse[]> {
return await this.request<UserResponse[]>('GET', 'api/user');
}
}
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
import {computed, ref} from 'vue'
import {useConfigStore} from '@/stores/config'
import {useRouter} from 'vue-router'
const props = defineProps<{
open?: boolean
}>()
const emit = defineEmits(['update:open'])
const configStore = useConfigStore()
const router = useRouter()
const serverAddress = ref('')
const selectedIdentity = ref('')
const identitiesOptions = computed(() => {
if (!configStore.config) return []
return configStore.config.identities.map(i => ({
label: i.username || i.id,
value: i.id
}))
})
async function joinServer() {
if (!serverAddress.value || !selectedIdentity.value) return
if (configStore.config) {
configStore.config.servers.push({
adresse: serverAddress.value,
identity: selectedIdentity.value
})
await configStore.saveConfig()
emit('update:open', false)
router.push('/config/servers')
}
}
const isOpen = computed({
get: () => !!props.open,
set: (val) => emit('update:open', val)
})
</script>
<template>
<UModal
v-model:open="isOpen"
title="Joindre un nouveau serveur"
description="Entrez l'adresse d'un serveur et choisissez une identité pour vous connecter."
>
<!-- <template #header>-->
<!-- <div class="p-4">-->
<!-- <h3 class="text-xl font-bold">Joindre un nouveau serveur</h3>-->
<!-- <p class="text-gray-500 text-sm">Entrez l'adresse d'un serveur et choisissez une identité pour vous-->
<!-- connecter.</p>-->
<!-- </div>-->
<!-- </template>-->
<template #body>
<div class="space-y-4 px-4 pb-4">
<UFormField label="Adresse du serveur (IP:Port)">
<UInput
v-model="serverAddress"
placeholder="ex: 127.0.0.1:50051"
icon="i-lucide-server"
class="w-full"
/>
</UFormField>
<UFormField label="Identité à utiliser">
<USelect
v-model="selectedIdentity"
:items="identitiesOptions"
placeholder="Sélectionner une identité"
icon="i-lucide-user"
class="w-full"
/>
</UFormField>
<p v-if="identitiesOptions.length === 0" class="text-xs text-red-500">
Vous devez d'abord créer une identité dans la configuration.
</p>
</div>
</template>
<template #footer>
<div class="flex justify-end gap-3 w-full px-4 py-3">
<UButton
color="neutral"
variant="ghost"
label="Annuler"
@click="isOpen = false"
/>
<UButton
label="Joindre"
:disabled="!serverAddress || !selectedIdentity"
@click="joinServer"
/>
</div>
</template>
</UModal>
</template>
+85
View File
@@ -0,0 +1,85 @@
<script setup lang="ts">
import {useConfigStore} from '../../stores/config'
const configStore = useConfigStore()
const toast = useToast()
</script>
<template>
<div class="space-y-4 py-4">
<template v-if="configStore.config">
<div v-for="(identity, index) in configStore.config.identities" :key="identity.id"
class="p-4 border border-gray-200 dark:border-gray-800 rounded-lg relative bg-white dark:bg-gray-900 shadow-sm">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormField label="ID d'identité (unique)">
<UInput v-model="identity.id" placeholder="ex: mon-id" class="w-full"/>
</UFormField>
<UFormField label="Nom d'utilisateur">
<UInput v-model="identity.username" placeholder="ex: MonPseudo" class="w-full"/>
</UFormField>
<UFormField label="Mode d'authentification" class="md:col-span-2">
<USelect
v-model="identity.mode"
:items="[
{ label: 'Clé (Fichier)', value: 'private_key_path' },
{ label: 'Clé (Base64)', value: 'private_key_base64' },
{ label: 'Login uniquement', value: 'login' }
]"
class="w-full"
/>
</UFormField>
<template v-if="identity.mode === 'private_key_path' || identity.mode === 'private_key_base64'">
<UFormField :label="identity.mode === 'private_key_path' ? 'Chemin de la clé' : 'Clé privée (Base64)'"
class="md:col-span-2">
<div class="space-y-2">
<UTextarea
v-model="identity.private_key"
:placeholder="identity.mode === 'private_key_path' ? 'ex: ~/.ssh/id_rsa' : 'Entrez la clé encodée en base64 (Si vide, une clé sera générée au besoin)'"
class="w-full"
/>
</div>
</UFormField>
</template>
<template v-if="identity.token">
<UFormField label="Jeton JWT (Persistant)" class="md:col-span-2">
<div class="flex gap-2">
<UInput v-model="identity.token" readonly class="flex-1 font-mono text-xs"/>
<UButton
icon="i-lucide-copy"
variant="ghost"
color="neutral"
@click="() => {
navigator.clipboard.writeText(identity.token || '')
toast.add({ title: 'Copié', description: 'Jeton JWT copié dans le presse-papier', color: 'green' })
}"
/>
</div>
</UFormField>
</template>
</div>
<UButton
icon="i-lucide-trash"
color="red"
variant="ghost"
size="sm"
class="absolute top-2 right-2"
@click="configStore.removeIdentity(index)"
/>
</div>
<UButton
icon="i-lucide-plus"
variant="dashed"
block
@click="configStore.addIdentity"
>
Ajouter une identité
</UButton>
</template>
</div>
</template>
+47
View File
@@ -0,0 +1,47 @@
<script setup lang="ts">
import {useConfigStore} from '../../stores/config'
const configStore = useConfigStore()
</script>
<template>
<div class="space-y-4 py-4">
<template v-if="configStore.config">
<div v-for="(server, index) in configStore.config.servers" :key="index"
class="p-4 border border-gray-200 dark:border-gray-800 rounded-lg relative bg-white dark:bg-gray-900 shadow-sm">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<UFormField label="Adresse du serveur (IP:Port)">
<UInput v-model="server.adresse" placeholder="ex: 127.0.0.1:50051" class="w-full"/>
</UFormField>
<UFormField label="Identité à utiliser">
<USelect
v-model="server.identity"
:items="configStore.config.identities.map(i => ({ label: i.username || i.id, value: i.id }))"
placeholder="Sélectionner une identité"
class="w-full"
/>
</UFormField>
</div>
<UButton
icon="i-lucide-trash"
color="red"
variant="ghost"
size="sm"
class="absolute top-2 right-2"
@click="configStore.removeServer(index)"
/>
</div>
<UButton
icon="i-lucide-plus"
variant="dashed"
block
@click="configStore.addServer"
>
Ajouter un serveur
</UButton>
</template>
</div>
</template>
+58
View File
@@ -0,0 +1,58 @@
import {invoke} from '@tauri-apps/api/core';
import type {IPlatform} from './types';
export class WebPlatform implements IPlatform {
readonly isTauri = false;
async saveToken(baseUrl: string, token: string): Promise<void> {
localStorage.setItem(`token_${baseUrl}`, token);
}
async loadToken(baseUrl: string): Promise<string | null> {
return localStorage.getItem(`token_${baseUrl}`);
}
async clearToken(baseUrl: string): Promise<void> {
localStorage.removeItem(`token_${baseUrl}`);
}
async startAudio(): Promise<void> {
console.warn('Audio is not supported in Web mode yet');
}
async stopAudio(): Promise<void> {
console.warn('Audio is not supported in Web mode yet');
}
async sendAudioPacket(data: ArrayBuffer | Uint8Array): Promise<void> {
console.warn('Audio is not supported in Web mode yet');
}
}
export class TauriPlatform implements IPlatform {
readonly isTauri = true;
async saveToken(baseUrl: string, token: string): Promise<void> {
await invoke('save_token', {baseUrl, token});
}
async loadToken(baseUrl: string): Promise<string | null> {
return await invoke<string | null>('load_token', {baseUrl});
}
async clearToken(baseUrl: string): Promise<void> {
await invoke('clear_token', {baseUrl});
}
async startAudio(): Promise<void> {
await invoke('start_audio');
}
async stopAudio(): Promise<void> {
await invoke('stop_audio');
}
async sendAudioPacket(data: ArrayBuffer | Uint8Array): Promise<void> {
await invoke('send_audio_packet', {data: Array.from(new Uint8Array(data))});
}
}
+14
View File
@@ -0,0 +1,14 @@
import {TauriPlatform, WebPlatform} from './implementations';
import type {IPlatform} from './types';
let platformInstance: IPlatform | null = null;
export function getPlatform(): IPlatform {
if (platformInstance) return platformInstance;
const isTauri = !!(window as any).__TAURI_INTERNALS__;
platformInstance = isTauri ? new TauriPlatform() : new WebPlatform();
return platformInstance;
}
export * from './types';
+18
View File
@@ -0,0 +1,18 @@
export interface IPlatform {
// Token handling
saveToken(baseUrl: string, token: string): Promise<void>;
loadToken(baseUrl: string): Promise<string | null>;
clearToken(baseUrl: string): Promise<void>;
// Audio control
startAudio(): Promise<void>;
stopAudio(): Promise<void>;
sendAudioPacket(data: ArrayBuffer | Uint8Array): Promise<void>;
// Environment
readonly isTauri: boolean;
}
+37
View File
@@ -0,0 +1,37 @@
import {createRouter, createWebHistory} from 'vue-router'
import IndexPage from './pages/index.vue'
import ConfigPage from './pages/config.vue'
import ServersPage from './pages/config/servers.vue'
import IdentitiesPage from './pages/config/identities.vue'
import ServerDetailPage from './pages/server_detail.vue'
import ChannelDetailPage from './pages/channel_detail.vue'
const router = createRouter({
routes: [
{path: '/', component: IndexPage},
{
path: '/config',
component: ConfigPage,
children: [
{path: '', redirect: '/config/servers'},
{path: 'servers', component: ServersPage},
{path: 'identities', component: IdentitiesPage},
]
},
{
path: '/server/:server_id',
component: ServerDetailPage,
props: true,
children: [
{
path: 'channel/:channel_id',
component: ChannelDetailPage,
props: true
},
]
}
],
history: createWebHistory()
})
export default router
+35
View File
@@ -0,0 +1,35 @@
import {defineStore} from 'pinia';
import {ref} from 'vue';
import {getPlatform} from '../platform';
export const useAuthStore = defineStore('auth', () => {
const tokens = ref<Record<string, string>>({});
async function loadToken(baseUrl: string): Promise<string | null> {
if (tokens.value[baseUrl]) {
return tokens.value[baseUrl];
}
const token = await getPlatform().loadToken(baseUrl);
if (token) {
tokens.value[baseUrl] = token;
}
return token;
}
async function saveToken(baseUrl: string, token: string): Promise<void> {
tokens.value[baseUrl] = token;
await getPlatform().saveToken(baseUrl, token);
}
async function clearToken(baseUrl: string): Promise<void> {
delete tokens.value[baseUrl];
await getPlatform().clearToken(baseUrl);
}
return {
tokens,
loadToken,
saveToken,
clearToken,
};
});