Création d'un composable pour la gestion de la crypto, mise en place de l'initialisation de la première liste et sauvegarde de celle-ci. TODO NEXT : Récupération de la liste en la déchiffrant, mise en place d'une masterKey dérivée du mot de passe pour les utilisateurs non google, basculement vers la page des listes après connexion google etc.
This commit is contained in:
177
app/components/button/google-code.vue
Normal file
177
app/components/button/google-code.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
useTokenClient,
|
||||
type TokenClientSuccessResponse,
|
||||
type TokenClientErrorResponse
|
||||
} from "vue3-google-signin";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const listStore = useListStore();
|
||||
const crypto = useCryptoStore()
|
||||
|
||||
/**
|
||||
* Logique Google Drive (appDataFolder)
|
||||
*/
|
||||
const handleDriveKey = async (accessToken: string) => {
|
||||
const fileName = 'list_master_key';
|
||||
const baseUrl = 'https://www.googleapis.com/drive/v3/files';
|
||||
const authHeader = { Authorization: `Bearer ${accessToken}` };
|
||||
|
||||
try {
|
||||
const searchParams = new URLSearchParams({
|
||||
q: `name = '${fileName}'`,
|
||||
spaces: 'appDataFolder',
|
||||
fields: 'files(id, name)'
|
||||
});
|
||||
|
||||
const searchRes = await $fetch<any>(`${baseUrl}?${searchParams}`, { headers: authHeader });
|
||||
|
||||
if (searchRes.files && searchRes.files.length > 0) {
|
||||
const fileId = searchRes.files[0].id;
|
||||
|
||||
// 1. Récupérer le contenu en tant que Blob ou ArrayBuffer pour préserver les bytes
|
||||
const blob = await $fetch<Blob>(`${baseUrl}/${fileId}?alt=media`, {
|
||||
headers: authHeader,
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
// Convertir le Blob en Uint8Array
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const keyUint8 = new Uint8Array(arrayBuffer);
|
||||
|
||||
listStore.setMasterKey(keyUint8);
|
||||
console.log("Clé binaire récupérée du Drive : " + keyUint8);
|
||||
} else {
|
||||
// 2. Création avec le type Uint8Array
|
||||
const newKey = crypto.generateMasterKey();
|
||||
|
||||
const metadata = {
|
||||
name: fileName,
|
||||
parents: ['appDataFolder']
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
// On spécifie explicitement 'application/octet-stream' pour la clé binaire
|
||||
formData.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
|
||||
formData.append('file', new Blob([newKey as any], { type: 'application/octet-stream' }));
|
||||
|
||||
await $fetch('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart', {
|
||||
method: 'POST',
|
||||
headers: authHeader,
|
||||
body: formData
|
||||
});
|
||||
|
||||
listStore.setMasterKey(newKey);
|
||||
console.log("Nouvelle clé binaire générée et stockée : " + newKey);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Erreur Google Drive:", err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Succès de l'authentification Google
|
||||
*/
|
||||
const handleSuccess = async (response: TokenClientSuccessResponse) => {
|
||||
const accessToken = response.access_token;
|
||||
console.log(response.access_token)
|
||||
|
||||
try {
|
||||
// // A. Vérification côté WordPress (Login)
|
||||
// // Note : On envoie l'accessToken ou on utilise l'ID Token si configuré.
|
||||
// // Si ton API WP attendait un 'code', il faudra l'adapter pour vérifier l'access_token ou l'email.
|
||||
const data = await $fetch<any>('/api-wp/auth/google', {
|
||||
method: 'POST',
|
||||
body: { access_token: accessToken }
|
||||
});
|
||||
|
||||
// Stockage des infos WP
|
||||
authStore.setTokenCookie(data.token);
|
||||
authStore.setUserCookie(data.user);
|
||||
|
||||
// B. Gestion de la clé sur Google Drive (Entièrement géré par le Front)
|
||||
await handleDriveKey(accessToken);
|
||||
listStore.processRawData(data.lists);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Erreur login WordPress:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = (error: TokenClientErrorResponse) => {
|
||||
console.error("Erreur Google:", error);
|
||||
};
|
||||
|
||||
// Initialisation du Token Client (Implicit Flow - Pas de secret nécessaire)
|
||||
const { login } = useTokenClient({
|
||||
scope: 'openid email profile https://www.googleapis.com/auth/drive.appdata',
|
||||
onSuccess: handleSuccess,
|
||||
onError: handleError,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button @click="() => login()" class="g-signin-button">
|
||||
<div class="g-logo-wrapper">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" class="g-logo">
|
||||
<path fill="#EA4335" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
|
||||
<path fill="#4285F4" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
|
||||
<path fill="#FBBC05" d="M10.53 28.59c-.48-1.45-.76-2.99-.76-4.59s.27-3.14.76-4.59l-7.98-6.19C.92 16.46 0 20.12 0 24c0 3.88.92 7.54 2.56 10.78l7.97-6.19z"/>
|
||||
<path fill="#34A853" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 6.19C6.51 42.62 14.62 48 24 48z"/>
|
||||
<path fill="none" d="M0 0h48v48H0z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="g-button-text">{{ $t('googleBtnTxt') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.g-signin-button {
|
||||
margin:auto;
|
||||
margin-top:1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
background-color: #ffffff;
|
||||
color: #757575;
|
||||
height: 40px;
|
||||
width: auto;
|
||||
border: 1px solid #dadce0;
|
||||
border-radius: 4px;
|
||||
padding: 0em;
|
||||
font-family: 'Roboto', arial, sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px 0 rgba(60,64,67,0.302), 0 1px 3px 1px rgba(60,64,67,0.149);
|
||||
transition: background-color .218s, border-color .218s, box-shadow .218s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.g-signin-button:hover {
|
||||
background-color: hsl(210, 17%, 90%);
|
||||
border-color: #d2d2d2;
|
||||
box-shadow: 0 1px 2px 0 rgba(60,64,67,0.302), 0 1px 3px 1px rgba(60,64,67,0.149);
|
||||
}
|
||||
|
||||
.g-signin-button:active {
|
||||
background-color: #eeeeee;
|
||||
}
|
||||
|
||||
.g-logo-wrapper {
|
||||
padding: 11px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.g-logo {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.g-button-text {
|
||||
padding: 0 12px 0 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -24,7 +24,7 @@
|
||||
authStore.setTokenCookie(data.token);
|
||||
authStore.setUserCookie(data.user);
|
||||
listStore.lists = data.lists
|
||||
console.log(listStore.lists) //ok
|
||||
//console.log(listStore.lists) //ok
|
||||
} catch (error) {
|
||||
console.error("Erreur côté WordPress:", error);
|
||||
}
|
||||
|
||||
199
app/composables/crypto.ts
Normal file
199
app/composables/crypto.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import sodium from "libsodium-wrappers"
|
||||
|
||||
export type KeyPair = {
|
||||
publicKey: Uint8Array
|
||||
privateKey: Uint8Array
|
||||
}
|
||||
|
||||
export type EncryptedPayload = {
|
||||
nonce: Uint8Array
|
||||
cipher: Uint8Array
|
||||
}
|
||||
|
||||
export type EncryptedPrivateKey = {
|
||||
nonce: Uint8Array
|
||||
cipher: Uint8Array
|
||||
salt: Uint8Array
|
||||
}
|
||||
|
||||
export const useCryptoStore = () => {
|
||||
|
||||
let ready = false
|
||||
|
||||
const init = async (): Promise<void> => {
|
||||
if (!ready) {
|
||||
await sodium.ready
|
||||
ready = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* USER KEY PAIR
|
||||
*/
|
||||
const generateUserKeyPair = (): KeyPair => {
|
||||
|
||||
const pair = sodium.crypto_box_keypair()
|
||||
|
||||
return {
|
||||
publicKey: pair.publicKey,
|
||||
privateKey: pair.privateKey
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MASTER KEY
|
||||
*/
|
||||
const generateMasterKey = (): Uint8Array => {
|
||||
return sodium.randombytes_buf(
|
||||
sodium.crypto_secretbox_KEYBYTES
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ENCRYPT PRIVATE KEY
|
||||
*/
|
||||
const encryptPrivateKey = (
|
||||
privateKey: Uint8Array,
|
||||
masterKey: Uint8Array
|
||||
): EncryptedPayload => {
|
||||
|
||||
const nonce = sodium.randombytes_buf(
|
||||
sodium.crypto_secretbox_NONCEBYTES
|
||||
)
|
||||
|
||||
const cipher = sodium.crypto_secretbox_easy(
|
||||
privateKey,
|
||||
nonce,
|
||||
masterKey
|
||||
)
|
||||
|
||||
return { nonce, cipher }
|
||||
}
|
||||
|
||||
/**
|
||||
* DECRYPT PRIVATE KEY
|
||||
*/
|
||||
const decryptPrivateKey = (
|
||||
encrypted: EncryptedPayload,
|
||||
masterKey: Uint8Array
|
||||
): Uint8Array => {
|
||||
|
||||
return sodium.crypto_secretbox_open_easy(
|
||||
encrypted.cipher,
|
||||
encrypted.nonce,
|
||||
masterKey
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* LIST KEY
|
||||
*/
|
||||
const generateListKey = (): Uint8Array => {
|
||||
|
||||
return sodium.randombytes_buf(
|
||||
sodium.crypto_aead_xchacha20poly1305_ietf_KEYBYTES
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ENCRYPT LIST DATA
|
||||
*/
|
||||
const encryptList = (
|
||||
data: string,
|
||||
listKey: Uint8Array
|
||||
): EncryptedPayload => {
|
||||
|
||||
const nonce = sodium.randombytes_buf(
|
||||
sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES
|
||||
)
|
||||
|
||||
const cipher = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(
|
||||
sodium.from_string(data),
|
||||
null,
|
||||
null,
|
||||
nonce,
|
||||
listKey
|
||||
)
|
||||
|
||||
return { nonce, cipher }
|
||||
}
|
||||
|
||||
/**
|
||||
* DECRYPT LIST DATA
|
||||
*/
|
||||
const decryptList = (
|
||||
encrypted: EncryptedPayload,
|
||||
listKey: Uint8Array
|
||||
): string => {
|
||||
|
||||
const decrypted = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
null,
|
||||
encrypted.cipher,
|
||||
null,
|
||||
encrypted.nonce,
|
||||
listKey
|
||||
)
|
||||
|
||||
return sodium.to_string(decrypted)
|
||||
}
|
||||
|
||||
/**
|
||||
* ENCRYPT LIST KEY FOR USER
|
||||
*/
|
||||
const encryptListKeyForUser = (
|
||||
listKey: Uint8Array,
|
||||
recipientPublicKey: Uint8Array,
|
||||
senderPrivateKey: Uint8Array
|
||||
): EncryptedPayload => {
|
||||
|
||||
const nonce = sodium.randombytes_buf(
|
||||
sodium.crypto_box_NONCEBYTES
|
||||
)
|
||||
|
||||
const cipher = sodium.crypto_box_easy(
|
||||
listKey,
|
||||
nonce,
|
||||
recipientPublicKey,
|
||||
senderPrivateKey
|
||||
)
|
||||
|
||||
return { nonce, cipher }
|
||||
}
|
||||
|
||||
/**
|
||||
* DECRYPT LIST KEY
|
||||
*/
|
||||
const decryptListKey = (
|
||||
encrypted: EncryptedPayload,
|
||||
senderPublicKey: Uint8Array,
|
||||
recipientPrivateKey: Uint8Array
|
||||
): Uint8Array => {
|
||||
|
||||
return sodium.crypto_box_open_easy(
|
||||
encrypted.cipher,
|
||||
encrypted.nonce,
|
||||
senderPublicKey,
|
||||
recipientPrivateKey
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
init,
|
||||
|
||||
generateUserKeyPair,
|
||||
|
||||
generateMasterKey,
|
||||
|
||||
encryptPrivateKey,
|
||||
decryptPrivateKey,
|
||||
|
||||
generateListKey,
|
||||
|
||||
encryptList,
|
||||
decryptList,
|
||||
|
||||
encryptListKeyForUser,
|
||||
decryptListKey
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
export const useCounter = () => {
|
||||
const counter = useState('counter', () => 0)
|
||||
const increment = () => {
|
||||
counter.value++
|
||||
}
|
||||
|
||||
const decrement = () => {
|
||||
counter.value--
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
counter.value=0
|
||||
}
|
||||
return {
|
||||
counter,
|
||||
increment,
|
||||
decrement,
|
||||
reset
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ const handleFormSubmit = async() => {
|
||||
</ButtonBase>
|
||||
</form>
|
||||
<p>{{ $t('loginGoogle') }}</p>
|
||||
<ButtonGoogle/>
|
||||
<ButtonGoogleCode/>
|
||||
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { $Fetch } from 'ofetch';
|
||||
import type { List } from '~/types/lists'
|
||||
import type { List, WPListEncrypted } from '~/types/lists'
|
||||
|
||||
export default class ListRepository {
|
||||
private fetcher: $Fetch;
|
||||
@@ -17,6 +17,23 @@ export default class ListRepository {
|
||||
method: 'POST',
|
||||
body: data
|
||||
});
|
||||
}
|
||||
|
||||
async uploadKeys(data: string) {
|
||||
console.log('uploadEncryptedPrivateKey : data is')
|
||||
console.log(data)
|
||||
|
||||
return await this.fetcher<any>('/lists/PrivKeyCipher', {
|
||||
method: 'POST',
|
||||
body: data
|
||||
})
|
||||
}
|
||||
|
||||
async update(data: WPListEncrypted) {
|
||||
const string = JSON.stringify(data);
|
||||
return await this.fetcher<any>('/lists/' + data.id, {
|
||||
method: 'PUT',
|
||||
body: string
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import type { List } from '~/types/lists'
|
||||
import type { List, WPListEncrypted } from '~/types/lists'
|
||||
import {useCryptoStore} from '~/composables/crypto'
|
||||
import sodium from "libsodium-wrappers"
|
||||
|
||||
|
||||
const crypto = useCryptoStore()
|
||||
|
||||
export const useListStore = defineStore('lists', {
|
||||
state: () => ({
|
||||
lists: [] as List[],
|
||||
loading: false as boolean,
|
||||
masterKey: null as Uint8Array | null,
|
||||
publicKey: new Uint8Array() as Uint8Array,
|
||||
privateKey: new Uint8Array() as Uint8Array,
|
||||
}),
|
||||
|
||||
actions: {
|
||||
@@ -15,8 +22,11 @@ export const useListStore = defineStore('lists', {
|
||||
resetLists(){
|
||||
this.lists = []
|
||||
},
|
||||
setMasterKey(key: Uint8Array){
|
||||
this.masterKey = key
|
||||
},
|
||||
|
||||
async fetchLists() {
|
||||
async fetchLists() {
|
||||
// On récupère notre plugin API injecté
|
||||
const { $api } = useNuxtApp();
|
||||
this.loading = true;
|
||||
@@ -33,7 +43,107 @@ export const useListStore = defineStore('lists', {
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async processRawData(rawData: [WPListEncrypted]){
|
||||
const { $api } = useNuxtApp();
|
||||
await crypto.init()
|
||||
|
||||
if (rawData && rawData.length > 0) {
|
||||
const item = rawData[0];
|
||||
if (item && this.masterKey){
|
||||
if (this.isWPListEncrypted(item)) {
|
||||
if (item.content_cipher === "initialize-me"){
|
||||
/* 1- Création de la paire de clés (TODO : à déporter ailleurs (Key Store ?)) */
|
||||
const { publicKey, privateKey } = crypto.generateUserKeyPair()
|
||||
this.publicKey = publicKey;
|
||||
this.privateKey = privateKey;
|
||||
|
||||
// On les envoie au BO //
|
||||
/* Chiffrage de la private avec la master : */
|
||||
const privKeyCipher = crypto.encryptPrivateKey(this.privateKey, this.masterKey)
|
||||
|
||||
/* empaquetage */
|
||||
const payload = {
|
||||
'user_id': item.user_id, // pour vérification au BO
|
||||
'private_key_cipher': privKeyCipher,
|
||||
'public_key': publicKey,
|
||||
}
|
||||
|
||||
/* Et on envoie ! */
|
||||
await $api.lists.uploadKeys(JSON.stringify(payload))
|
||||
|
||||
// On crée la liste //
|
||||
/* D'abord la clé AES de la liste */
|
||||
const key = crypto.generateListKey();
|
||||
|
||||
/* Puis la liste */
|
||||
const user_id = Number(item.user_id)
|
||||
|
||||
const data: List = {
|
||||
id: Number(item.id),
|
||||
user_id: user_id,
|
||||
aesKey: key,
|
||||
list_title: 'THE VERY première liste',
|
||||
list_type: 'basic',
|
||||
content: '{[\'vide\']}',
|
||||
is_open: true,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
}
|
||||
this.lists[0] = data
|
||||
|
||||
/* Et on envoie au BO */
|
||||
this.syncData(data, this.publicKey, this.privateKey)
|
||||
}
|
||||
else{
|
||||
console.log('coucou : ' + rawData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
async syncData(decryptedData: List, userPublicKey: Uint8Array, userPrivateKey: Uint8Array) {
|
||||
const { $api } = useNuxtApp();
|
||||
|
||||
const aesKey = decryptedData.aesKey;
|
||||
await crypto.init();
|
||||
|
||||
// 1. On prépare les données (en excluant la clé elle-même du contenu)
|
||||
const { aesKey: _, ...pureData } = decryptedData;
|
||||
const stringData = JSON.stringify(pureData);
|
||||
|
||||
// 2. Chiffrement du contenu par la clé AES (XChaCha20)
|
||||
const encryptedContent = crypto.encryptList(stringData, aesKey);
|
||||
|
||||
// 3. Chiffrement de la clé AES par la clé Publique (Asymétrique)
|
||||
// On utilise ici ta fonction de l'étape 11
|
||||
const encryptedKeyPayload = crypto.encryptListKeyForUser(
|
||||
aesKey,
|
||||
userPublicKey,
|
||||
userPrivateKey
|
||||
);
|
||||
|
||||
// 4. On empaquette le tout pour le Back-Office
|
||||
const dataToBO: WPListEncrypted = {
|
||||
id: decryptedData.id.toString(),
|
||||
user_id: decryptedData.user_id.toString(),
|
||||
|
||||
// La clé AES verrouillée pour l'utilisateur
|
||||
key_cipher: sodium.to_base64(encryptedKeyPayload.cipher),
|
||||
key_nonce: sodium.to_base64(encryptedKeyPayload.nonce),
|
||||
|
||||
// Le contenu de la liste
|
||||
content_cipher: sodium.to_base64(encryptedContent.cipher),
|
||||
content_nonce: sodium.to_base64(encryptedContent.nonce),
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
};
|
||||
|
||||
await $api.lists.update(dataToBO)
|
||||
},
|
||||
|
||||
// async updateList(id, title, content) {
|
||||
// const config = useRuntimeConfig();
|
||||
@@ -57,6 +167,26 @@ export const useListStore = defineStore('lists', {
|
||||
// console.error("Erreur lors de la récupération des listes:", error);
|
||||
// }
|
||||
// }
|
||||
|
||||
isWPListEncrypted(obj: any): obj is WPListEncrypted {
|
||||
console.log(obj !== null)
|
||||
console.log(typeof obj)
|
||||
console.log(typeof obj.id)
|
||||
console.log(typeof obj.key_cipher )
|
||||
console.log(typeof obj.key_nonce )
|
||||
console.log(typeof obj.content_cipher)
|
||||
console.log(typeof obj.content_nonce)
|
||||
|
||||
return (
|
||||
obj !== null &&
|
||||
typeof obj === 'object' &&
|
||||
typeof obj.id === 'string' &&
|
||||
typeof obj.key_cipher === 'string' &&
|
||||
typeof obj.key_nonce === 'string' &&
|
||||
typeof obj.content_cipher === 'string' &&
|
||||
typeof obj.content_nonce === 'string'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
@@ -1,11 +1,24 @@
|
||||
export interface WPListEncrypted {
|
||||
id: string;
|
||||
user_id: string;
|
||||
key_cipher: string; // La "data" est cachée là-dedans sous forme de string chiffrée
|
||||
key_nonce: string;
|
||||
content_cipher: string;
|
||||
content_nonce: string;
|
||||
created_at: number | null; // Souvent gardé en clair pour le tri côté front
|
||||
updated_at: number | null;
|
||||
}
|
||||
|
||||
export interface List {
|
||||
id: number;
|
||||
user_id: number;
|
||||
aesKey: Uint8Array;
|
||||
list_title: string;
|
||||
list_type: string;
|
||||
encrypted_content: string; // JSON string
|
||||
content: string; // JSON string
|
||||
is_open: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
export interface taskItem {
|
||||
|
||||
Reference in New Issue
Block a user