init
This commit is contained in:
@@ -0,0 +1 @@
|
||||
use tauri::command;
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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))});
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user