Compare commits

..

3 Commits

6 changed files with 151 additions and 73 deletions

View File

@ -226,6 +226,14 @@
@apply bg-[var(--dark-background)]; @apply bg-[var(--dark-background)];
} }
.sidebar-item-content {
@apply flex flex-col;
}
.sidebar-item-bottom-row {
@apply mt-1 flex items-center justify-between;
}
.sidebar-item-text { .sidebar-item-text {
@apply text-xs text-[var(--light-text)]/60; @apply text-xs text-[var(--light-text)]/60;
} }
@ -234,6 +242,30 @@
@apply text-[var(--dark-text)]/60; @apply text-[var(--dark-text)]/60;
} }
.sidebar-item-delete {
@apply flex h-6.5 w-6.5 items-center justify-center rounded-lg border-1 border-[var(--light-accent)]/20 bg-transparent p-1;
}
.dark .sidebar-item-delete {
@apply border-[var(--dark-accent)]/20;
}
.sidebar-item-delete > svg {
@apply text-[var(--light-accent)]/50;
}
.dark .sidebar-item-delete > svg {
@apply text-[var(--dark-accent)]/50;
}
.sidebar-item-delete:hover > svg {
@apply text-[var(--light-accent)] transition-colors delay-100;
}
.dark .sidebar-item-delete:hover > svg {
@apply text-[var(--dark-accent)];
}
.sidebar-divider { .sidebar-divider {
@apply divide-y divide-[var(--light-text)]/20; @apply divide-y divide-[var(--light-text)]/20;
} }

View File

@ -107,7 +107,7 @@ export const cError: Writable<string | null> = writable(null)
class ApiClient { class ApiClient {
private viewCookieName: string private viewCookieName: string
private baseUrl: string private baseUrl: string
private lastAtUpdate = new Date(0) private lastAtUpdate = new Date(0) // Refreshing the page wipes access and CSRF tokens from memory -> Rotation needed
private lastCsrfUpdate = new Date(0) private lastCsrfUpdate = new Date(0)
private refreshInProgress = false private refreshInProgress = false
private activeVersion = -1 private activeVersion = -1
@ -123,7 +123,7 @@ class ApiClient {
private async handleRequest<T>( private async handleRequest<T>(
fn: () => Promise<T>, fn: () => Promise<T>,
options: { useBearerAuth: boolean } options: { useBearerAuth: boolean }
): Promise<T | undefined> { ): Promise<T | null> {
isPending.set(true) isPending.set(true)
cError.set(null) cError.set(null)
@ -137,18 +137,21 @@ class ApiClient {
try { try {
await this.checkAndRefreshAccessToken() await this.checkAndRefreshAccessToken()
} catch (err) { } catch (err) {
console.log("refresh attempt not successful") console.log("[REQ] Refresh attempt not successful")
await this.handleLocalLogout() await this.handleLocalLogout()
throw new Error("Session expired, please authenticate again.") throw new Error("Session expired, please authenticate again.")
} }
} }
return await fn() return await fn()
} catch (err) { } catch (err) {
cError.set(err instanceof Error ? err.message : "Unknown error") cError.set(err instanceof Error ? err.message : "Unknown error")
console.log(`error: ${get(cError)}`) console.log(`[ERR] ${get(cError)}`)
} finally { } finally {
isPending.set(false) isPending.set(false)
} }
return null
} }
// Should be attached to routes that handle authentication with the bearer token (access token) // Should be attached to routes that handle authentication with the bearer token (access token)
@ -161,12 +164,12 @@ class ApiClient {
// This should never happen due to the token expiration checks we make client-side, // This should never happen due to the token expiration checks we make client-side,
// but it's still good to have as a fallback // but it's still good to have as a fallback
try { try {
console.log("unexpected 401 caught, attempting refresh") console.log("[RES] Unexpected 401 caught, attempting to refresh...")
await this.checkAndRefreshAccessToken() await this.checkAndRefreshAccessToken()
} catch (err) { } catch (err) {
console.log("refresh attempt not successful") const errMsg = err instanceof Error ? err.message : "Unknown error"
await this.handleLocalLogout() await this.handleLocalLogout()
throw new Error("Session expired, please authenticate again.") throw new Error(errMsg)
} }
} }
@ -192,20 +195,16 @@ class ApiClient {
const timeSinceUpdate = Date.now() - this.lastAtUpdate.getTime() const timeSinceUpdate = Date.now() - this.lastAtUpdate.getTime()
const needsRefresh = timeSinceUpdate > AT_EXP_MS - REFRESH_BUF const needsRefresh = timeSinceUpdate > AT_EXP_MS - REFRESH_BUF
console.log(`timeSinceUpdate: ${timeSinceUpdate}`)
if (needsRefresh) { if (needsRefresh) {
console.log("running token refresh attempt") console.log(`[AUTH] Running token refresh attempt (timeSinceUpdate=${timeSinceUpdate})`)
this.refreshInProgress = true this.refreshInProgress = true
await this.refreshAccessToken() await this.refreshAccessToken()
this.refreshInProgress = false this.refreshInProgress = false
} else {
console.log("no need to rotate tokens")
} }
} }
private async refreshAccessToken(): Promise<void> { private async refreshAccessToken(): Promise<void | null> {
return this.handleRequest( return this.handleRequest(
async () => { async () => {
const response = await fetch(`${this.baseUrl}/auth/cookie/refresh`, { const response = await fetch(`${this.baseUrl}/auth/cookie/refresh`, {
@ -235,7 +234,7 @@ class ApiClient {
const needsRefresh = timeSinceUpdate > CSRF_EXP_MS - REFRESH_BUF const needsRefresh = timeSinceUpdate > CSRF_EXP_MS - REFRESH_BUF
if (!token || needsRefresh) { if (!token || needsRefresh) {
console.log("refreshing csrf token") console.log("[AUTH] Refreshing CSRF token")
await this.refreshCsrfToken() await this.refreshCsrfToken()
token = get(csrfToken) token = get(csrfToken)
} }
@ -243,7 +242,7 @@ class ApiClient {
return { "X-Csrf-Token": token || "" } return { "X-Csrf-Token": token || "" }
} }
private async refreshCsrfToken(): Promise<void> { private async refreshCsrfToken(): Promise<void | null> {
return this.handleRequest( return this.handleRequest(
async () => { async () => {
const response = await fetch(`${this.baseUrl}/auth/cookie/csrf`, { const response = await fetch(`${this.baseUrl}/auth/cookie/csrf`, {
@ -263,7 +262,7 @@ class ApiClient {
) )
} }
private async handleLocalLogout(): Promise<void> { private async handleLocalLogout(): Promise<void | null> {
this.lastAtUpdate = new Date(0) this.lastAtUpdate = new Date(0)
this.lastCsrfUpdate = new Date(0) this.lastCsrfUpdate = new Date(0)
@ -286,7 +285,7 @@ class ApiClient {
const viewCookie = this.getCookieValue(this.viewCookieName) const viewCookie = this.getCookieValue(this.viewCookieName)
if (!viewCookie) { if (!viewCookie) {
console.log("view cookie not found") console.log("[AUTH] View cookie not found")
return false return false
} }
@ -294,14 +293,11 @@ class ApiClient {
const expirationTimestamp = parseInt(value, 10) const expirationTimestamp = parseInt(value, 10)
if (isNaN(expirationTimestamp)) { if (isNaN(expirationTimestamp)) {
console.log(`invalid expiration timestamp: ${value}`) console.log(`[ERR] Invalid view cookie expiration timestamp: ${value}`)
return false return false
} }
const expirationDate = new Date(expirationTimestamp * 1000) return now < new Date(expirationTimestamp * 1000)
console.log(`auth cookie expiration: ${expirationDate}`)
return now < expirationDate
} }
private deleteViewCookie() { private deleteViewCookie() {
@ -378,12 +374,12 @@ class ApiClient {
private joinDeserializedVersion( private joinDeserializedVersion(
noteID: string, noteID: string,
apiResponse: ApiFullVersionResponse apiResponse: ApiFullVersionResponse
): FullNote | undefined { ): FullNote | null {
// Cache lookups are safe here due to this always being called *after* fetching the actual `FullNote` // Cache lookups are safe here due to this always being called *after* fetching the actual `FullNote`
const cachedNote = this.loadedNotesCache.get(noteID) const cachedNote = this.loadedNotesCache.get(noteID)
if (!cachedNote) { if (!cachedNote) {
return return null
} }
return { return {
@ -399,7 +395,7 @@ class ApiClient {
} }
} }
public async register(username: string, password: string): Promise<void> { public async register(username: string, password: string): Promise<void | null> {
return this.handleRequest( return this.handleRequest(
async () => { async () => {
const response = await fetch(`${this.baseUrl}/auth/signup`, { const response = await fetch(`${this.baseUrl}/auth/signup`, {
@ -408,20 +404,21 @@ class ApiClient {
body: JSON.stringify({ username, password }) body: JSON.stringify({ username, password })
}) })
// Can't overwrite `username` parameter // Can't overwrite the function parameter
const data = await this.handleResponse<{ const data = await this.handleResponse<{
id: string id: string
username: string username: string
}>(response, { useBearerAuth: false }) }>(response, { useBearerAuth: false })
console.log(`${data.username} -> ${data.id}`) console.log(`[USER] Registration of user '${data.username}' successful`)
await goto("/login") await goto("/login")
}, },
{ useBearerAuth: false } { useBearerAuth: false }
) )
} }
public async login(username: string, password: string): Promise<void> { public async login(username: string, password: string): Promise<void | null> {
return this.handleRequest( return this.handleRequest(
async () => { async () => {
const response = await fetch(`${this.baseUrl}/auth/login`, { const response = await fetch(`${this.baseUrl}/auth/login`, {
@ -444,7 +441,7 @@ class ApiClient {
) )
} }
public async logout(): Promise<void> { public async logout(): Promise<void | null> {
return this.handleRequest( return this.handleRequest(
async () => { async () => {
const response = await fetch(`${this.baseUrl}/auth/logout`, { const response = await fetch(`${this.baseUrl}/auth/logout`, {
@ -456,7 +453,7 @@ class ApiClient {
}) })
if (response.status === 204) { if (response.status === 204) {
console.log("logout successful") console.log("[USER] Logout successful")
await this.handleLocalLogout() await this.handleLocalLogout()
return return
} }
@ -468,7 +465,7 @@ class ApiClient {
) )
} }
public async getCurrentUser(): Promise<void> { public async getCurrentUser(): Promise<void | null> {
return this.handleRequest( return this.handleRequest(
async () => { async () => {
const response = await fetch(`${this.baseUrl}/auth/me`, { const response = await fetch(`${this.baseUrl}/auth/me`, {
@ -479,15 +476,16 @@ class ApiClient {
const data = await this.handleResponse<ApiUserResponse>(response, { useBearerAuth: false }) const data = await this.handleResponse<ApiUserResponse>(response, { useBearerAuth: false })
const user = this.deserializeUser(data) const user = this.deserializeUser(data)
console.log(user)
currentUser.set(user) currentUser.set(user)
}, },
{ useBearerAuth: true } { useBearerAuth: true }
) )
} }
public async updateCurrentUserPassword(oldPassword: string, newPassword: string): Promise<void> { public async updateCurrentUserPassword(
oldPassword: string,
newPassword: string
): Promise<void | null> {
return this.handleRequest( return this.handleRequest(
async () => { async () => {
const data = { const data = {
@ -502,21 +500,21 @@ class ApiClient {
}, },
body: JSON.stringify(data) body: JSON.stringify(data)
}) })
const { accessToken: token, user } = await this.handleResponse<{ const { accessToken: token, user } = await this.handleResponse<{
accessToken: string accessToken: string
user: User user: User
}>(response, { useBearerAuth: false }) }>(response, { useBearerAuth: false })
accessToken.set(token) accessToken.set(token)
currentUser.set(user || null) currentUser.set(user || null)
this.lastAtUpdate = new Date() this.lastAtUpdate = new Date()
console.log(user)
}, },
{ useBearerAuth: true } { useBearerAuth: true }
) )
} }
public async deleteCurrentUser(password: string): Promise<void> { public async deleteCurrentUser(password: string): Promise<void | null> {
return this.handleRequest( return this.handleRequest(
async () => { async () => {
const response = await fetch(`${this.baseUrl}/auth/owner`, { const response = await fetch(`${this.baseUrl}/auth/owner`, {
@ -529,7 +527,7 @@ class ApiClient {
}) })
if (response.status === 204) { if (response.status === 204) {
console.log("deletion successful") console.log("[USER] Deletion successful")
await this.handleLocalLogout() await this.handleLocalLogout()
return return
} }
@ -541,7 +539,7 @@ class ApiClient {
) )
} }
public async adminListAll(): Promise<User[] | undefined> { public async adminListAll(): Promise<User[] | null> {
const user = get(currentUser) const user = get(currentUser)
if (!user || !user.isAdmin) { if (!user || !user.isAdmin) {
throw new Error("Admin privileges required.") throw new Error("Admin privileges required.")
@ -559,7 +557,7 @@ class ApiClient {
}) })
const users = await this.handleResponse<User[]>(response, { useBearerAuth: false }) const users = await this.handleResponse<User[]>(response, { useBearerAuth: false })
console.log(`admin: got ${users.length} user results`) console.log(`[ADMIN] Got ${users.length} user results`)
return users return users
}, },
@ -567,7 +565,7 @@ class ApiClient {
) )
} }
public async adminDeleteUser(userID: string): Promise<void> { public async adminDeleteUser(userID: string): Promise<void | null> {
const user = get(currentUser) const user = get(currentUser)
if (!user || !user.isAdmin) { if (!user || !user.isAdmin) {
throw new Error("Admin privileges required.") throw new Error("Admin privileges required.")
@ -587,7 +585,7 @@ class ApiClient {
}) })
if (response.status === 204) { if (response.status === 204) {
console.log("admin: deletion successful") console.log(`[ADMIN] Deletion of user '${userID}' successful`)
return return
} }
@ -597,7 +595,7 @@ class ApiClient {
) )
} }
public async listNotes(): Promise<NoteMetadata[] | undefined> { public async listNotes(): Promise<NoteMetadata[] | null> {
return this.handleRequest( return this.handleRequest(
async () => { async () => {
const params = new URLSearchParams() const params = new URLSearchParams()
@ -618,7 +616,7 @@ class ApiClient {
notes = this.deserializeNoteMetadatas(data) notes = this.deserializeNoteMetadatas(data)
} }
console.log(`got ${notes.length} note metadata results`) console.log(`[NOTE] Got ${notes.length} note metadata results`)
return notes return notes
}, },
@ -626,7 +624,7 @@ class ApiClient {
) )
} }
public async createNote(): Promise<NewNoteResponse | undefined> { public async createNote(): Promise<NewNoteResponse | null> {
// NOTE: The initial note version doesn't allow any user input, the first user-made modification // NOTE: The initial note version doesn't allow any user input, the first user-made modification
// is applied through the version creation endpoint // is applied through the version creation endpoint
@ -644,10 +642,7 @@ class ApiClient {
) )
} }
public async getActiveFullNote( public async getActiveFullNote(noteID: string, fetchRemote: boolean): Promise<FullNote | null> {
noteID: string,
fetchRemote: boolean
): Promise<FullNote | undefined> {
if (!UUID_REGEX.test(noteID)) { if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.") throw new Error("Invalid note ID format.")
} }
@ -656,7 +651,6 @@ class ApiClient {
if (!fetchRemote) { if (!fetchRemote) {
const cachedNote = this.loadedNotesCache.get(noteID) const cachedNote = this.loadedNotesCache.get(noteID)
if (cachedNote != null) { if (cachedNote != null) {
// console.log(`full note cache hit ${noteID}`)
this.activeVersion = cachedNote.versionNumber this.activeVersion = cachedNote.versionNumber
return cachedNote return cachedNote
} }
@ -675,7 +669,7 @@ class ApiClient {
}) })
const note = this.deserializeFullNote(data) const note = this.deserializeFullNote(data)
console.log(`caching ${noteID}`) console.log(`[CACHE] Storing ${noteID}`)
this.loadedNotesCache.set(noteID, note) this.loadedNotesCache.set(noteID, note)
this.activeVersion = note.versionNumber this.activeVersion = note.versionNumber
@ -685,7 +679,7 @@ class ApiClient {
) )
} }
public async deleteNote(noteID: string): Promise<void> { public async deleteNote(noteID: string): Promise<void | null> {
if (!UUID_REGEX.test(noteID)) { if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.") throw new Error("Invalid note ID format.")
} }
@ -700,7 +694,7 @@ class ApiClient {
}) })
if (response.status === 204) { if (response.status === 204) {
console.log("deletion successful") console.log("[NOTE] Deletion successful")
return return
} }
@ -713,7 +707,7 @@ class ApiClient {
public async getNoteHistory( public async getNoteHistory(
noteID: string, noteID: string,
fetchRemote: boolean fetchRemote: boolean
): Promise<VersionMetadata[] | undefined> { ): Promise<VersionMetadata[] | null> {
if (!UUID_REGEX.test(noteID)) { if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.") throw new Error("Invalid note ID format.")
} }
@ -721,7 +715,6 @@ class ApiClient {
if (!fetchRemote) { if (!fetchRemote) {
const cachedVersions = this.loadedHistoryCache.get(noteID) const cachedVersions = this.loadedHistoryCache.get(noteID)
if (cachedVersions != null) { if (cachedVersions != null) {
// console.log(`full version cache hit ${noteID}`)
return cachedVersions return cachedVersions
} }
} }
@ -739,8 +732,8 @@ class ApiClient {
}) })
const versions = this.deserializeVersionMetadatas(data) const versions = this.deserializeVersionMetadatas(data)
console.log(`got ${versions.length} version metadata results, caching ${noteID}`)
this.loadedHistoryCache.set(noteID, versions) this.loadedHistoryCache.set(noteID, versions)
console.log(`[VER] Got and cached ${versions.length} version metadata results`)
return versions return versions
}, },
@ -748,7 +741,7 @@ class ApiClient {
) )
} }
public async createVersion(noteID: string, title: string, content: string): Promise<void> { public async createVersion(noteID: string, title: string, content: string): Promise<void | null> {
if (!UUID_REGEX.test(noteID)) { if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.") throw new Error("Invalid note ID format.")
} }
@ -767,7 +760,7 @@ class ApiClient {
}) })
if (response.status === 204) { if (response.status === 204) {
console.log("creation successful") console.log("[VER] Creation successful")
return return
} }
@ -777,7 +770,7 @@ class ApiClient {
) )
} }
public async getFullVersion(noteID: string, versionID: string): Promise<FullNote | undefined> { public async getFullVersion(noteID: string, versionID: string): Promise<FullNote | null> {
if (!UUID_REGEX.test(noteID)) { if (!UUID_REGEX.test(noteID)) {
throw new Error("Invalid note ID format.") throw new Error("Invalid note ID format.")
} }
@ -786,18 +779,13 @@ class ApiClient {
throw new Error("Invalid version ID format.") throw new Error("Invalid version ID format.")
} }
// NOTE: Versions aren't editable so we don't need to prevent the system from attempting // NOTE: No need to explicitly prevent attempting a cache hit as versions aren't editable
// to locate each request's contents first from the cache
const cachedVersion = this.loadedVersionsCache.get(noteID + versionID) const cachedVersion = this.loadedVersionsCache.get(noteID + versionID)
if (cachedVersion != null) { if (cachedVersion != null) {
// console.log(`full version cache hit [${noteID}, ${versionID}]`)
return cachedVersion return cachedVersion
} }
// TODO: check if the requested version is the current version -> use `loadedNotesCache`
// (we probably have to modify the caching mechanism so we can look the regular note items up using versionID)
return this.handleRequest( return this.handleRequest(
async () => { async () => {
const response = await fetch(`${this.baseUrl}/notes/${noteID}/${versionID}`, { const response = await fetch(`${this.baseUrl}/notes/${noteID}/${versionID}`, {
@ -812,10 +800,10 @@ class ApiClient {
const version = this.joinDeserializedVersion(noteID, data) const version = this.joinDeserializedVersion(noteID, data)
if (!version) { if (!version) {
return return null
} }
console.log(`caching [${noteID}, ${versionID}]`) console.log(`[CACHE] Storing [${noteID}, ${versionID}]`)
this.loadedVersionsCache.set(noteID + versionID, version) this.loadedVersionsCache.set(noteID + versionID, version)
return version return version

View File

@ -164,6 +164,22 @@
} }
} }
const deleteNote = async (noteID: string) => {
try {
await apiClient.deleteNote(noteID)
// If we're deleting the currently active note, clear the current note
if ($currentFullNote && $currentFullNote.id === noteID) {
currentFullNote.set(null)
}
// Refresh the notes list due to updates being pushed to server
await loadNotes()
} catch (err) {
cError.set(`Failed to delete note: ${err instanceof Error ? err.message : "Unknown error"}`)
}
}
const logout = async () => { const logout = async () => {
await apiClient.logout() await apiClient.logout()
} }
@ -190,6 +206,7 @@
{logout} {logout}
{createNewNote} {createNewNote}
{selectNote} {selectNote}
{deleteNote}
on:close={closeSidebar} on:close={closeSidebar}
/> />

View File

@ -2,6 +2,7 @@
import type { NoteMetadata, FullNote } from "$lib/client" import type { NoteMetadata, FullNote } from "$lib/client"
import Close from "$lib/icons/Close.svelte" import Close from "$lib/icons/Close.svelte"
import CreateNew from "$lib/icons/CreateNew.svelte" import CreateNew from "$lib/icons/CreateNew.svelte"
import Delete from "$lib/icons/Delete.svelte"
import Logout from "$lib/icons/Logout.svelte" import Logout from "$lib/icons/Logout.svelte"
import Search from "$lib/icons/Search.svelte" import Search from "$lib/icons/Search.svelte"
import Settings from "$lib/icons/Settings.svelte" import Settings from "$lib/icons/Settings.svelte"
@ -13,7 +14,8 @@
export let toggleSettings: () => void export let toggleSettings: () => void
export let logout: () => Promise<void> export let logout: () => Promise<void>
export let createNewNote: () => Promise<void> export let createNewNote: () => Promise<void>
export let selectNote: (noteId: string, fetchRemote: boolean) => Promise<void> export let selectNote: (noteID: string, fetchRemote: boolean) => Promise<void>
export let deleteNote: (noteID: string) => Promise<void>
const formatDate = (dateString: string | Date): string => { const formatDate = (dateString: string | Date): string => {
if (!dateString) { if (!dateString) {
@ -35,6 +37,14 @@
sidebarOpen = false sidebarOpen = false
} }
const handleDeleteNote = (event: MouseEvent, noteID: string) => {
event.stopPropagation()
if (confirm("Are you sure you want to delete this note?")) {
deleteNote(noteID)
}
}
const handleNoteKeydown = (event: KeyboardEvent, noteID: string) => { const handleNoteKeydown = (event: KeyboardEvent, noteID: string) => {
if (event.key === "Enter" || event.key === " ") { if (event.key === "Enter" || event.key === " ") {
event.preventDefault() // Prevent page scroll on space event.preventDefault() // Prevent page scroll on space
@ -103,10 +113,24 @@
role="option" role="option"
aria-selected={currentNote && note.id === currentNote.id} aria-selected={currentNote && note.id === currentNote.id}
> >
<h3 class="truncate font-bold">{note.title || "Untitled Note"}</h3> <div class="sidebar-item-content">
<p class="sidebar-item-text mt-1 italic"> <!-- Note metadata description -->
{formatDate(note.updatedAt)} <h3 class="truncate font-bold">{note.title || "Untitled Note"}</h3>
</p> <div class="sidebar-item-bottom-row">
<p class="sidebar-item-text mt-1 italic">
{formatDate(note.updatedAt)}
</p>
<!-- Note delete button -->
<button
on:click={(e) => handleDeleteNote(e, note.id)}
class="sidebar-item-delete"
aria-label="Delete note"
>
<Delete />
</button>
</div>
</div>
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@ -2,7 +2,7 @@
// will automatically be proxied to the correct destination // will automatically be proxied to the correct destination
export const API_BASE_ADDR = import.meta.env.PROD ? "/api" : "http://localhost:8080/api" export const API_BASE_ADDR = import.meta.env.PROD ? "/api" : "http://localhost:8080/api"
// Lifetimes of *in-memory* authentication tokens // Lifetimes of *in-memory* authentication tokens in milliseconds
export const AT_EXP_MS = 15 * 60 * 1000 // 15 min. export const AT_EXP_MS = 15 * 60 * 1000 // 15 min.
export const CSRF_EXP_MS = 12 * 60 * 60 * 1000 // 12 h. export const CSRF_EXP_MS = 12 * 60 * 60 * 1000 // 12 h.
export const REFRESH_BUF = 30 * 1000 // 30 s. export const REFRESH_BUF = 30 * 1000 // 30 s.

View File

@ -0,0 +1,17 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor">
<path d="M10 12V17" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M14 12V17" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M4 7H20" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M6 10V18C6 19.6569 7.34315 21 9 21H15C16.6569 21 18 19.6569 18 18V10"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M9 5C9 3.89543 9.89543 3 11 3H13C14.1046 3 15 3.89543 15 5V7H9V5Z"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>

After

Width:  |  Height:  |  Size: 673 B