(feat): add cache package
(feat): improve local storage security (feat): move settings to dedicated settings package
This commit is contained in:
parent
fb095db7a6
commit
790a1db788
54 changed files with 1984 additions and 947 deletions
|
|
@ -17,6 +17,13 @@ import {
|
|||
type DesktopScreenShareCapabilities,
|
||||
} from "../shared/ipc.js";
|
||||
import { initTray, setTrayCallStatus } from "./tray.js";
|
||||
import {
|
||||
clearSecureStorage,
|
||||
deleteSecureStorage,
|
||||
getSecureStorageStatus,
|
||||
loadSecureStorage,
|
||||
saveSecureStorage,
|
||||
} from "./secureStorage.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const verbose = process.argv.includes("--verbose");
|
||||
|
|
@ -190,6 +197,18 @@ function registerIpc() {
|
|||
);
|
||||
ipcMain.handle(ipcChannels.getVersion, () => app.getVersion());
|
||||
ipcMain.handle(ipcChannels.checkForUpdates, checkForUpdates);
|
||||
ipcMain.handle(ipcChannels.getSecureStorageStatus, getSecureStorageStatus);
|
||||
ipcMain.handle(ipcChannels.loadSecureStorage, (_event, key: unknown) =>
|
||||
loadSecureStorage(key),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.saveSecureStorage,
|
||||
(_event, key: unknown, value: unknown) => saveSecureStorage(key, value),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.deleteSecureStorage, (_event, key: unknown) =>
|
||||
deleteSecureStorage(key),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.clearSecureStorage, clearSecureStorage);
|
||||
ipcMain.handle(ipcChannels.setCallStatus, (_event, status: unknown) => {
|
||||
if (
|
||||
typeof status !== "object" ||
|
||||
|
|
|
|||
130
apps/electron/src/main/secureStorage.ts
Normal file
130
apps/electron/src/main/secureStorage.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import { app, safeStorage } from "electron";
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
secureStorageLimits,
|
||||
type DesktopSecureStorageStatus,
|
||||
} from "../shared/ipc.js";
|
||||
|
||||
type StoredValues = Record<string, string>;
|
||||
|
||||
let pendingWrite = Promise.resolve();
|
||||
|
||||
function storagePath() {
|
||||
return join(app.getPath("userData"), "secure-storage.json");
|
||||
}
|
||||
|
||||
function validateKey(key: unknown): asserts key is string {
|
||||
if (
|
||||
typeof key !== "string" ||
|
||||
key.length === 0 ||
|
||||
Buffer.byteLength(key, "utf8") > secureStorageLimits.maxKeyBytes
|
||||
) {
|
||||
throw new Error("Invalid secure storage key.");
|
||||
}
|
||||
}
|
||||
|
||||
function validateValue(value: unknown): asserts value is string {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
Buffer.byteLength(value, "utf8") > secureStorageLimits.maxValueBytes
|
||||
) {
|
||||
throw new Error("Invalid secure storage value.");
|
||||
}
|
||||
}
|
||||
|
||||
export function getSecureStorageStatus(): DesktopSecureStorageStatus {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { available: false, backend: null };
|
||||
}
|
||||
|
||||
const backend =
|
||||
process.platform === "linux"
|
||||
? safeStorage.getSelectedStorageBackend()
|
||||
: process.platform === "darwin"
|
||||
? "keychain"
|
||||
: process.platform === "win32"
|
||||
? "dpapi"
|
||||
: null;
|
||||
|
||||
return {
|
||||
available: process.platform !== "linux" || backend !== "basic_text",
|
||||
backend,
|
||||
};
|
||||
}
|
||||
|
||||
function requireAvailable() {
|
||||
if (!getSecureStorageStatus().available) {
|
||||
throw new Error("Secure storage is unavailable.");
|
||||
}
|
||||
}
|
||||
|
||||
async function readValues(): Promise<StoredValues> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(await readFile(storagePath(), "utf8"));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Invalid secure storage data.");
|
||||
}
|
||||
|
||||
const values = parsed as Record<string, unknown>;
|
||||
if (Object.values(values).some((value) => typeof value !== "string")) {
|
||||
throw new Error("Invalid secure storage data.");
|
||||
}
|
||||
return values as StoredValues;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeValues(values: StoredValues) {
|
||||
const path = storagePath();
|
||||
const temporaryPath = `${path}.tmp`;
|
||||
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
||||
await writeFile(temporaryPath, JSON.stringify(values), { mode: 0o600 });
|
||||
await rename(temporaryPath, path);
|
||||
}
|
||||
|
||||
function mutateValues(mutation: (values: StoredValues) => void) {
|
||||
const operation = pendingWrite.then(async () => {
|
||||
const values = await readValues();
|
||||
mutation(values);
|
||||
await writeValues(values);
|
||||
});
|
||||
pendingWrite = operation.catch(() => undefined);
|
||||
return operation;
|
||||
}
|
||||
|
||||
export async function loadSecureStorage(key: unknown): Promise<string | null> {
|
||||
requireAvailable();
|
||||
validateKey(key);
|
||||
await pendingWrite;
|
||||
const encrypted = (await readValues())[key];
|
||||
if (encrypted === undefined) return null;
|
||||
return safeStorage.decryptString(Buffer.from(encrypted, "base64"));
|
||||
}
|
||||
|
||||
export function saveSecureStorage(key: unknown, value: unknown) {
|
||||
requireAvailable();
|
||||
validateKey(key);
|
||||
validateValue(value);
|
||||
const encrypted = safeStorage.encryptString(value).toString("base64");
|
||||
return mutateValues((values) => {
|
||||
values[key] = encrypted;
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteSecureStorage(key: unknown) {
|
||||
requireAvailable();
|
||||
validateKey(key);
|
||||
return mutateValues((values) => {
|
||||
delete values[key];
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSecureStorage() {
|
||||
requireAvailable();
|
||||
return mutateValues((values) => {
|
||||
for (const key of Object.keys(values)) delete values[key];
|
||||
});
|
||||
}
|
||||
|
|
@ -3,12 +3,21 @@ import {
|
|||
ipcChannels,
|
||||
type DesktopCallStatus,
|
||||
type DesktopScreenShareSource,
|
||||
secureStorageLimits,
|
||||
} from "../shared/ipc.js";
|
||||
|
||||
function windowAction(channel: string) {
|
||||
return () => ipcRenderer.invoke(channel);
|
||||
}
|
||||
|
||||
function validKey(key: string) {
|
||||
return (
|
||||
typeof key === "string" &&
|
||||
key.length > 0 &&
|
||||
Buffer.byteLength(key, "utf8") <= secureStorageLimits.maxKeyBytes
|
||||
);
|
||||
}
|
||||
|
||||
const desktopApi = {
|
||||
media: {
|
||||
listScreenShareSources: () =>
|
||||
|
|
@ -46,6 +55,24 @@ const desktopApi = {
|
|||
return ipcRenderer.invoke(ipcChannels.setCallStatus, status);
|
||||
},
|
||||
},
|
||||
secureStorage: {
|
||||
getStatus: () => ipcRenderer.invoke(ipcChannels.getSecureStorageStatus),
|
||||
load: (key: string) =>
|
||||
validKey(key)
|
||||
? ipcRenderer.invoke(ipcChannels.loadSecureStorage, key)
|
||||
: Promise.reject(new Error("Invalid secure storage key.")),
|
||||
save: (key: string, value: string) =>
|
||||
validKey(key) &&
|
||||
typeof value === "string" &&
|
||||
Buffer.byteLength(value, "utf8") <= secureStorageLimits.maxValueBytes
|
||||
? ipcRenderer.invoke(ipcChannels.saveSecureStorage, key, value)
|
||||
: Promise.reject(new Error("Invalid secure storage key or value.")),
|
||||
delete: (key: string) =>
|
||||
validKey(key)
|
||||
? ipcRenderer.invoke(ipcChannels.deleteSecureStorage, key)
|
||||
: Promise.reject(new Error("Invalid secure storage key.")),
|
||||
clear: () => ipcRenderer.invoke(ipcChannels.clearSecureStorage),
|
||||
},
|
||||
window: {
|
||||
minimize: () => windowAction(ipcChannels.minimizeWindow),
|
||||
maximize: () => windowAction(ipcChannels.maximizeWindow),
|
||||
|
|
|
|||
|
|
@ -26,6 +26,16 @@ export type DesktopCallStatus = {
|
|||
iconDataUrl?: string;
|
||||
};
|
||||
|
||||
export type DesktopSecureStorageStatus = {
|
||||
available: boolean;
|
||||
backend: string | null;
|
||||
};
|
||||
|
||||
export const secureStorageLimits = {
|
||||
maxKeyBytes: 256,
|
||||
maxValueBytes: 1024 * 1024,
|
||||
} as const;
|
||||
|
||||
export type ReleaseArtifact = {
|
||||
name: string;
|
||||
platform: string;
|
||||
|
|
@ -62,4 +72,9 @@ export const ipcChannels = {
|
|||
getVersion: "app:getVersion",
|
||||
checkForUpdates: "updates:checkForUpdates",
|
||||
setCallStatus: "call:setStatus",
|
||||
getSecureStorageStatus: "secureStorage:getStatus",
|
||||
loadSecureStorage: "secureStorage:load",
|
||||
saveSecureStorage: "secureStorage:save",
|
||||
deleteSecureStorage: "secureStorage:delete",
|
||||
clearSecureStorage: "secureStorage:clear",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -50,9 +50,11 @@
|
|||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tensamin/call": "workspace:*",
|
||||
"@tensamin/cache": "workspace:*",
|
||||
"@tensamin/chat": "workspace:*",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/settings": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/tauri": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -58,7 +58,6 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
<House className="size-4.5" />
|
||||
</Button>
|
||||
<Button
|
||||
// @ts-expect-error TanStack router doesn't properly detect the settings route
|
||||
onClick={() => navigate({ to: "/settings" })}
|
||||
className="w-9 h-9 aspect-square rounded-lg"
|
||||
variant="outline"
|
||||
|
|
@ -195,7 +194,6 @@ export function MobileNavbar() {
|
|||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// @ts-expect-error TanStack router doesn't properly detect the settings route
|
||||
navigate({ to: "/settings" });
|
||||
setOpenMobile(false);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,30 @@ export default function Form() {
|
|||
const uploadRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const { save } = useStorage();
|
||||
const loginPendingRef = React.useRef(false);
|
||||
|
||||
const persistLogin = React.useCallback(
|
||||
async (userId: number, privateKey: string, domain?: string | null) => {
|
||||
if (loginPendingRef.current) return false;
|
||||
loginPendingRef.current = true;
|
||||
try {
|
||||
if (domain) await save("omega_url", `https://${domain}/`);
|
||||
await save("mtp_keyring", privateKey, { secure: true });
|
||||
await save("session_id", Date.now());
|
||||
await save("user_id", userId);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.protocol === "file:" ? "#/" : "/",
|
||||
);
|
||||
window.location.reload();
|
||||
return true;
|
||||
} finally {
|
||||
loginPendingRef.current = false;
|
||||
}
|
||||
},
|
||||
[save],
|
||||
);
|
||||
|
||||
// Process dropped files
|
||||
const processDroppedFile = React.useCallback(
|
||||
|
|
@ -93,20 +117,13 @@ export default function Form() {
|
|||
const raw = await file.text();
|
||||
const parsed = parseTuFileContent(raw);
|
||||
|
||||
await save("session_id", Date.now());
|
||||
await save("user_id", parsed.userId);
|
||||
await save("mtp_keyring", parsed.privateKey);
|
||||
if (parsed.domain) {
|
||||
await save("omega_url", `https://${parsed.domain}/`);
|
||||
}
|
||||
|
||||
location.href = "/";
|
||||
await persistLogin(parsed.userId, parsed.privateKey, parsed.domain);
|
||||
} catch (error) {
|
||||
log(0, "login", "red", error);
|
||||
toast("error", "Failed to load file");
|
||||
}
|
||||
},
|
||||
[save],
|
||||
[persistLogin],
|
||||
);
|
||||
|
||||
// Handle .tu files
|
||||
|
|
@ -236,20 +253,13 @@ export default function Form() {
|
|||
|
||||
const user = parse.data;
|
||||
|
||||
await save("session_id", Date.now());
|
||||
await save("user_id", user.user_id);
|
||||
await save("mtp_keyring", inputParse.data.mtp_keyring);
|
||||
if (domain) {
|
||||
await save("omega_url", `https://${domain}/`);
|
||||
}
|
||||
|
||||
location.href = "/";
|
||||
await persistLogin(user.user_id, inputParse.data.mtp_keyring, domain);
|
||||
} catch (error) {
|
||||
log(0, "login", "red", error);
|
||||
toast("error", "Failed to fetch user data");
|
||||
}
|
||||
},
|
||||
[save],
|
||||
[persistLogin],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -269,15 +279,7 @@ export default function Form() {
|
|||
const { userId, privateKey, domain } =
|
||||
parseTuFileContent(decoded);
|
||||
|
||||
await save("session_id", Date.now());
|
||||
await save("user_id", userId);
|
||||
await save("mtp_keyring", privateKey);
|
||||
|
||||
if (domain) {
|
||||
await save("omega_url", `https://${domain}/`);
|
||||
}
|
||||
|
||||
location.href = "/";
|
||||
await persistLogin(userId, privateKey, domain);
|
||||
} catch (error) {
|
||||
log(0, "login", "red", error);
|
||||
toast("error", "Failed to parse QR code data");
|
||||
|
|
|
|||
|
|
@ -1,178 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
Input,
|
||||
Label,
|
||||
Switch as UISwitch,
|
||||
} from "@tensamin/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { storageDefaults, type Storage } from "@tensamin/shared/data";
|
||||
import { settingsStorageDefaults } from "@tensamin/shared/settings";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
|
||||
type BooleanStorageKey = {
|
||||
[K in keyof Storage]: Storage[K] extends boolean ? K : never;
|
||||
}[keyof Storage];
|
||||
|
||||
type ListStorageKey = {
|
||||
[K in keyof Storage]: Storage[K] extends (string | number)[] ? K : never;
|
||||
}[keyof Storage];
|
||||
|
||||
type ListStorageItem<K extends ListStorageKey> =
|
||||
Storage[K] extends Array<infer Item> ? Item : never;
|
||||
|
||||
export function Switch({
|
||||
label,
|
||||
id,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
id: keyof typeof settingsStorageDefaults & BooleanStorageKey;
|
||||
}) {
|
||||
const { save, load } = useStorage();
|
||||
const [value, setValue] = useState<boolean>(settingsStorageDefaults[id]);
|
||||
|
||||
useEffect(() => {
|
||||
load(id).then((value) => setValue(value));
|
||||
}, [id, load]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<UISwitch
|
||||
id={id}
|
||||
checked={value}
|
||||
onCheckedChange={(value) => {
|
||||
setValue(value);
|
||||
save(id, value);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function List<K extends ListStorageKey>({
|
||||
label,
|
||||
id,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
id: K;
|
||||
}) {
|
||||
const { save, load } = useStorage();
|
||||
const [items, setItems] = useState<Storage[K]>(storageDefaults[id]);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
load(id).then((value) => setItems(value));
|
||||
}, [id, load]);
|
||||
|
||||
const persistItems = (nextItems: Storage[K]) => {
|
||||
setItems(nextItems);
|
||||
save(id, nextItems);
|
||||
};
|
||||
|
||||
const toStorageItem = (value: string): ListStorageItem<K> => {
|
||||
const referenceItem = items[0] ?? storageDefaults[id][0];
|
||||
|
||||
if (typeof referenceItem === "number") {
|
||||
return Number(value) as ListStorageItem<K>;
|
||||
}
|
||||
|
||||
return value as ListStorageItem<K>;
|
||||
};
|
||||
|
||||
const addItem = () => {
|
||||
const trimmedValue = inputValue.trim();
|
||||
if (!trimmedValue) return;
|
||||
|
||||
const nextItem = toStorageItem(trimmedValue);
|
||||
if (typeof nextItem === "number" && Number.isNaN(nextItem)) return;
|
||||
|
||||
persistItems([...items, nextItem] as Storage[K]);
|
||||
setInputValue("");
|
||||
};
|
||||
|
||||
const deleteItems = (indexes: Set<number>) => {
|
||||
const nextItems = items.filter(
|
||||
(_, index) => !indexes.has(index),
|
||||
) as Storage[K];
|
||||
|
||||
setItems(nextItems);
|
||||
setSelectedItems(new Set());
|
||||
save(id, nextItems);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pt-4">
|
||||
<Label>{label}</Label>
|
||||
<div className="flex flex-col gap-0 overflow-hidden p-1 border-2 rounded-xl">
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={inputValue}
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") addItem();
|
||||
}}
|
||||
/>
|
||||
<Button onClick={addItem}>Add item</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0">
|
||||
{items.map((item, index) => {
|
||||
const labelId = `${String(id)}-${index}`;
|
||||
const selected = selectedItems.has(index);
|
||||
const deletingSelectedItems = selectedItems.size > 1;
|
||||
|
||||
return (
|
||||
<ContextMenu key={`${String(item)}-${index}`}>
|
||||
<ContextMenuTrigger
|
||||
render={
|
||||
<div className="grid grid-cols-[auto_auto_1fr] items-center gap-2 border-b px-1 py-2 last:border-b-0">
|
||||
<Checkbox
|
||||
id={labelId}
|
||||
checked={selected}
|
||||
onCheckedChange={(checked) => {
|
||||
setSelectedItems((previous) => {
|
||||
const nextSelected = new Set(previous);
|
||||
|
||||
if (checked) {
|
||||
nextSelected.add(index);
|
||||
} else {
|
||||
nextSelected.delete(index);
|
||||
}
|
||||
|
||||
return nextSelected;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor={labelId}>{String(item)}</Label>
|
||||
<div />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={() =>
|
||||
deleteItems(
|
||||
deletingSelectedItems
|
||||
? selectedItems
|
||||
: new Set([index]),
|
||||
)
|
||||
}
|
||||
>
|
||||
{deletingSelectedItems ? "Delete Selected" : "Delete"}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import { Button, ClearStorageButton } from "@tensamin/ui";
|
||||
|
||||
import options from "@tensamin/shared/settings";
|
||||
import { cn, useIsMobile } from "@tensamin/ui";
|
||||
import { Outlet, useLocation, useNavigate } from "@tanstack/react-router";
|
||||
|
||||
export default function Screen() {
|
||||
const isMobile = useIsMobile();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{!isMobile && <SettingsSidebar />}
|
||||
|
||||
{/* Page */}
|
||||
<div className="bg-background w-full h-full p-3 flex flex-col gap-3">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{location.pathname
|
||||
.split("/")
|
||||
.pop()
|
||||
?.replace(/-/g, " ")
|
||||
.replace(/\b\w/g, (l) => l.toUpperCase())}
|
||||
</h1>
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSidebar() {
|
||||
const settingsOptions = options as Record<
|
||||
string,
|
||||
Record<string, Record<string, unknown>>
|
||||
>;
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
isMobile
|
||||
? "w-full p-1"
|
||||
: "p-3 rounded-tl-2xl border-r bg-input/15 w-50",
|
||||
"flex flex-col gap-6",
|
||||
)}
|
||||
>
|
||||
{/* Settings */}
|
||||
{Object.keys(settingsOptions).map((category) => (
|
||||
// Category
|
||||
<div key={category} className="flex flex-col gap-2">
|
||||
<h2 className="font-bold text-xs uppercase">{category}</h2>
|
||||
{Object.keys(settingsOptions[category]).map((page) => (
|
||||
// Page
|
||||
<div key={page}>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
navigate({
|
||||
to: "/settings/" + page.toLowerCase(),
|
||||
})
|
||||
}
|
||||
>
|
||||
{(page as string).charAt(0).toUpperCase() +
|
||||
(page as string).slice(1)}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-auto">
|
||||
<ClearStorageButton className="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ import "@tensamin/ui/index.css";
|
|||
import NotFound from "@/routes/404";
|
||||
|
||||
import AppLayout from "@/routes/app/layout";
|
||||
import SettingsLayout from "@/features/settings/layout";
|
||||
import { createSettingsRoute } from "@tensamin/settings";
|
||||
|
||||
import Home from "@/routes/app/home";
|
||||
import ChatScreen from "@tensamin/chat/screen";
|
||||
|
|
@ -41,8 +41,10 @@ import Storage from "@tensamin/storage/context";
|
|||
import Session from "@tensamin/storage/session";
|
||||
import Crypto from "@tensamin/crypto/context";
|
||||
import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
|
||||
import LegalWrapper from "@/features/legal/screen";
|
||||
import CacheSync from "@tensamin/cache/sync";
|
||||
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
|
|
@ -71,34 +73,39 @@ window.setLogLevelToMax = () => {
|
|||
function LoginWrapper({ children }: { children: ReactNode }) {
|
||||
const [loggedIn, setLoggedIn] = useState<boolean | null>(null);
|
||||
|
||||
const { load } = useStorage();
|
||||
const { load, secureStorage } = useStorage();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (secureStorage === null) return;
|
||||
let active = true;
|
||||
|
||||
load("user_id").then((userId) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (userId !== 0) {
|
||||
setLoggedIn(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoggedIn(false);
|
||||
navigate({
|
||||
to: "/login",
|
||||
Promise.all([load("user_id"), load("mtp_keyring")])
|
||||
.then(([userId, keyring]) => {
|
||||
if (!active) return;
|
||||
if (userId !== 0 && keyring !== "") {
|
||||
setLoggedIn(true);
|
||||
if (location.pathname === "/login") {
|
||||
void navigate({ to: "/", replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
setLoggedIn(false);
|
||||
void navigate({
|
||||
to: "/login",
|
||||
replace: true,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
log(0, "login", "red", "Failed to load login state", error);
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [load, navigate]);
|
||||
}, [load, location.pathname, navigate, secureStorage]);
|
||||
|
||||
if (loggedIn !== true && location.pathname !== "/login") {
|
||||
return null;
|
||||
|
|
@ -256,6 +263,7 @@ function RootShell() {
|
|||
function AppShell() {
|
||||
return (
|
||||
<MTPProvider>
|
||||
<CacheSync />
|
||||
<Session>
|
||||
<UserProvider>
|
||||
<CallInit />
|
||||
|
|
@ -382,47 +390,7 @@ const appRoute = createRoute({
|
|||
notFoundComponent: NotFound,
|
||||
});
|
||||
|
||||
const settingsRoute = createRoute({
|
||||
getParentRoute: () => appRoute,
|
||||
path: "settings",
|
||||
component: SettingsLayout,
|
||||
staticData: {
|
||||
showMobileNavbar: true,
|
||||
},
|
||||
});
|
||||
|
||||
type SettingsRouteModule = {
|
||||
default?: () => React.JSX.Element;
|
||||
component?: () => React.JSX.Element;
|
||||
};
|
||||
|
||||
const settingsRouteModules = import.meta.glob<SettingsRouteModule>(
|
||||
"./routes/settings/*.tsx",
|
||||
{ eager: true },
|
||||
);
|
||||
|
||||
const settingsChildren = Object.entries(settingsRouteModules).map(
|
||||
([filePath, module]) => {
|
||||
const fileName = filePath.split("/").pop()?.replace(".tsx", "") ?? "";
|
||||
const path = fileName === "index" ? "/" : fileName;
|
||||
const component = module.component ?? module.default;
|
||||
|
||||
if (!component) {
|
||||
throw new Error(
|
||||
`Settings route module "${filePath}" must export a default component`,
|
||||
);
|
||||
}
|
||||
|
||||
return createRoute({
|
||||
getParentRoute: () => settingsRoute,
|
||||
path,
|
||||
component,
|
||||
staticData: {
|
||||
showMobileNavbar: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
const settingsRoute = createSettingsRoute(appRoute);
|
||||
|
||||
const homeRoute = createRoute({
|
||||
getParentRoute: () => appRoute,
|
||||
|
|
@ -464,12 +432,7 @@ const loginRoute = createRoute({
|
|||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
appRoute.addChildren([
|
||||
homeRoute,
|
||||
chatRoute,
|
||||
callRoute,
|
||||
settingsRoute.addChildren(settingsChildren),
|
||||
]),
|
||||
appRoute.addChildren([homeRoute, chatRoute, callRoute, settingsRoute]),
|
||||
loginRoute,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,15 +16,35 @@ import { useState } from "react";
|
|||
import { Loader2 } from "lucide-react";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { useSession } from "@tensamin/storage/session";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
|
||||
// The page
|
||||
export default function Page() {
|
||||
const isMobile = useIsMobile();
|
||||
const { secureStorage } = useStorage();
|
||||
|
||||
return (
|
||||
<div className={`px-3 flex gap-2 ${!(isTauri() && isMobile) && "pt-3"}`}>
|
||||
<AddConversationButton />
|
||||
<Button disabled>Add Community</Button>
|
||||
<div
|
||||
className={`px-3 flex flex-col gap-3 ${!(isTauri() && isMobile) && "pt-3"}`}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<AddConversationButton />
|
||||
<Button disabled>Add Community</Button>
|
||||
</div>
|
||||
{secureStorage && !secureStorage.secure && (
|
||||
<div className="flex max-w-2xl gap-3 rounded-lg border border-(--destructive)/60 bg-(--destructive)/10 p-3 text-sm">
|
||||
<ShieldAlert className="mt-0.5 size-5 shrink-0 text-destructive" />
|
||||
<div>
|
||||
<p className="font-medium">Secure storage is unavailable</p>
|
||||
<p>{secureStorage.reason}</p>
|
||||
<p className="text-muted-foreground">
|
||||
Your keyring and cached messages will get saved in regular
|
||||
storage.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import { List, Switch } from "@/features/settings/components";
|
||||
import { Button, Kbd } from "@tensamin/ui";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { storageDefaults } from "@tensamin/shared/data";
|
||||
|
||||
export default function Page() {
|
||||
const { save } = useStorage();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Switch
|
||||
label={
|
||||
<p>
|
||||
Change <Kbd>Enter</Kbd> behavior to <Kbd>Shift</Kbd> +{" "}
|
||||
<Kbd>Enter</Kbd>
|
||||
</p>
|
||||
}
|
||||
id="settings.reverse_enter_behavior"
|
||||
/>
|
||||
<Switch
|
||||
label="Enable read confirmations"
|
||||
id="settings.read_confirmations"
|
||||
/>
|
||||
<Switch
|
||||
label="Enable receive confirmations"
|
||||
id="settings.receive_confirmations"
|
||||
/>
|
||||
<Switch
|
||||
label="Sidebar message preview"
|
||||
id="settings.show_start_of_last_message_in_sidebar"
|
||||
/>
|
||||
<p className="text-destructive pt-6">
|
||||
Trusted embed domains can get your IP-Address! Only add domains if you
|
||||
really trust them!
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void save("reactions", storageDefaults.reactions)}
|
||||
>
|
||||
Reset Emoji Ranks
|
||||
</Button>
|
||||
<List label="Trusted embed domains" id="chat_trusted_domains" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { SettingsSidebar } from "@/features/settings/layout";
|
||||
import { useIsMobile } from "@tensamin/ui";
|
||||
|
||||
export default function Page() {
|
||||
const isMobile = useIsMobile();
|
||||
return isMobile && <SettingsSidebar />;
|
||||
}
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardTitle,
|
||||
CardHeader,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
Button,
|
||||
Badge,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTrigger,
|
||||
} from "@tensamin/ui";
|
||||
import {
|
||||
packages,
|
||||
packageCount,
|
||||
generatedAt,
|
||||
} from "../../../../../licenses/third-party-credits.json";
|
||||
|
||||
const licenseTexts = import.meta.glob("../../../../../licenses/**/*", {
|
||||
eager: true,
|
||||
import: "default",
|
||||
query: "?raw",
|
||||
}) as Record<string, string>;
|
||||
|
||||
function getLicenseFiles(licensePackage: (typeof packages)[number]) {
|
||||
return licensePackage.files.map((fileName) => {
|
||||
const path =
|
||||
"../../../../../" + licensePackage.licenseFolder + "/" + fileName;
|
||||
|
||||
return {
|
||||
fileName,
|
||||
text: licenseTexts[path],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col gap-7">
|
||||
<div className="flex flex-col">
|
||||
<p>Last generated: {generatedAt}</p>
|
||||
<p>Package Count: {packageCount}</p>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 max-h-[calc(100vh-180px)] overflow-auto pr-2">
|
||||
<div className="flex flex-col gap-5">
|
||||
{packages.map((licensePackage) => (
|
||||
<Card
|
||||
key={licensePackage.name + licensePackage.version}
|
||||
id={licensePackage.name + licensePackage.version}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex gap-2 items-center">
|
||||
<Badge>{licensePackage.license}</Badge> {licensePackage.name}{" "}
|
||||
{licensePackage.version}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
{licensePackage.description && (
|
||||
<CardContent>
|
||||
<CardDescription>
|
||||
{licensePackage.description}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
)}
|
||||
<CardFooter className="gap-2">
|
||||
<LicenseDialog licensePackage={licensePackage} />
|
||||
{licensePackage.repository ? (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={licensePackage.repository
|
||||
?.replace("git+", "")
|
||||
.replace(".git", "")}
|
||||
>
|
||||
<Button variant="outline" className="cursor-pointer">
|
||||
Open Repository
|
||||
</Button>
|
||||
</a>
|
||||
) : (
|
||||
<Button disabled variant="outline" className="cursor-pointer">
|
||||
Open Repository
|
||||
</Button>
|
||||
)}
|
||||
{licensePackage.homepage ? (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
href={licensePackage.homepage}
|
||||
>
|
||||
<Button variant="outline" className="cursor-pointer">
|
||||
Open Homepage
|
||||
</Button>
|
||||
</a>
|
||||
) : (
|
||||
<Button disabled variant="outline" className="cursor-pointer">
|
||||
Open Homepage
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LicenseDialog({
|
||||
licensePackage,
|
||||
}: {
|
||||
licensePackage: (typeof packages)[number];
|
||||
}) {
|
||||
const licenseFiles = getLicenseFiles(licensePackage);
|
||||
const hasLicenseText = licenseFiles.some(({ text }) => text);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button disabled={!hasLicenseText} className="cursor-pointer">
|
||||
Open License
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DialogContent className="flex max-h-[85vh] min-h-0 flex-col overflow-hidden sm:max-w-3xl">
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-2">
|
||||
{licenseFiles.map(({ fileName, text }, index) => (
|
||||
<section key={fileName} className="border-b last:border-b-0">
|
||||
<h3
|
||||
className={`border-b pb-2 text-sm font-medium ${index >= 1 && "pt-2"}`}
|
||||
>
|
||||
{fileName}
|
||||
</h3>
|
||||
<pre className="pt-2 whitespace-pre-wrap wrap-break-word text-xs leading-relaxed">
|
||||
{text ||
|
||||
"License text could not be loaded. Please contact support@tensamin.net"}
|
||||
</pre>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,218 +0,0 @@
|
|||
import { useStorage } from "@tensamin/storage/context";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Button,
|
||||
cn,
|
||||
Input,
|
||||
useIsMobile,
|
||||
} from "@tensamin/ui";
|
||||
import { useUser, type User } from "@tensamin/user/context";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import MDInput from "@tensamin/markdown/input";
|
||||
import { mtp } from "@tensamin/shared/data";
|
||||
import { useMTP } from "@tensamin/mtp";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
async function prepImage(
|
||||
file: File,
|
||||
size = 300,
|
||||
quality = 0.8,
|
||||
): Promise<string> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Could not get canvas context");
|
||||
|
||||
const scale = Math.max(size / bitmap.width, size / bitmap.height);
|
||||
const width = bitmap.width * scale;
|
||||
const height = bitmap.height * scale;
|
||||
const x = (size - width) / 2;
|
||||
const y = (size - height) / 2;
|
||||
|
||||
ctx.drawImage(bitmap, x, y, width, height);
|
||||
|
||||
return canvas.toDataURL("image/webp", quality);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { get } = useUser();
|
||||
const { load } = useStorage();
|
||||
const { send } = useMTP();
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [draftUser, setDraftUser] = useState<Partial<User>>({});
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [saveSucceeded, setSaveSucceeded] = useState(false);
|
||||
const avatarUploadRef = useRef<HTMLInputElement>(null);
|
||||
const draftInitializedRef = useRef(false);
|
||||
const effectiveAvatar =
|
||||
draftUser.Avatar === "none" ? undefined : draftUser.Avatar;
|
||||
|
||||
const updateDraftUser = (
|
||||
updater: (previous: Partial<User>) => Partial<User>,
|
||||
) => {
|
||||
setSaveSucceeded(false);
|
||||
setErrorMessage("");
|
||||
setDraftUser(updater);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
const user = await get(await load("user_id"));
|
||||
setCurrentUser(user);
|
||||
};
|
||||
|
||||
fetchUser();
|
||||
}, [load, get]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentUser || draftInitializedRef.current) return;
|
||||
|
||||
setDraftUser(currentUser);
|
||||
draftInitializedRef.current = true;
|
||||
}, [currentUser]);
|
||||
|
||||
const handleAvatarUpload = async (file: File) => {
|
||||
const final = await prepImage(file);
|
||||
updateDraftUser((prev) => ({ ...prev, avatar: final }));
|
||||
if (avatarUploadRef.current) {
|
||||
avatarUploadRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return currentUser ? (
|
||||
<>
|
||||
<input
|
||||
ref={avatarUploadRef}
|
||||
hidden
|
||||
onChange={(e) =>
|
||||
e.target.files?.[0] && handleAvatarUpload(e.target.files[0])
|
||||
}
|
||||
type="file"
|
||||
/>
|
||||
<div className={cn("flex flex-col gap-5", isMobile ? "w-full" : "w-80")}>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-14">
|
||||
<AvatarImage src={effectiveAvatar} />
|
||||
<AvatarFallback className="text-2xl">
|
||||
{draftUser.Display?.slice(0, 2).toUpperCase() ||
|
||||
currentUser.Display.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p>Avatar</p>
|
||||
<div className="flex gap-1">
|
||||
<Button onClick={() => avatarUploadRef.current?.click()}>
|
||||
Upload avatar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateDraftUser((prev) => {
|
||||
return { ...prev, avatar: "none" };
|
||||
});
|
||||
}}
|
||||
variant="destructive"
|
||||
disabled={effectiveAvatar === undefined}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
GIFs are supported in decentralised mode or with Tensamin Premium.
|
||||
<br />
|
||||
Maximum file size is 16mb.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
className="w-full"
|
||||
onChange={(event) =>
|
||||
updateDraftUser((prev) => ({
|
||||
...prev,
|
||||
display: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Display Name"
|
||||
value={draftUser.Display || ""}
|
||||
/>
|
||||
<Input
|
||||
className="w-full"
|
||||
onChange={(event) =>
|
||||
updateDraftUser((prev) => ({
|
||||
...prev,
|
||||
username: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Username"
|
||||
value={draftUser.Username || ""}
|
||||
/>
|
||||
<MDInput
|
||||
styled
|
||||
paddingY="4px"
|
||||
paddingX="10px"
|
||||
fontSize=".875rem"
|
||||
placeholder="About Me"
|
||||
setValue={(value) =>
|
||||
updateDraftUser((prev) => ({ ...prev, about: value }))
|
||||
}
|
||||
value={draftUser.About || ""}
|
||||
/>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const { Avatar, ...draftUsersWithoutAvatar } = draftUser;
|
||||
const payload = {
|
||||
...draftUsersWithoutAvatar,
|
||||
...(typeof Avatar === "string"
|
||||
? {
|
||||
avatar: Avatar.startsWith("data:")
|
||||
? (Avatar.split(",", 2)[1] ?? "")
|
||||
: Avatar,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const validation = mtp.ChangeUserData.request.safeParse(payload);
|
||||
|
||||
if (!validation.success) {
|
||||
setSaveSucceeded(false);
|
||||
setErrorMessage(
|
||||
validation.error.issues[0]?.message ?? "Invalid profile data",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await send("ChangeUserData", validation.data);
|
||||
setSaveSucceeded(true);
|
||||
setErrorMessage("");
|
||||
} catch (err) {
|
||||
setSaveSucceeded(false);
|
||||
setErrorMessage("Failed to update profile: " + err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{saveSucceeded ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Check className="size-4" />
|
||||
Saved
|
||||
</span>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</Button>
|
||||
{errorMessage && (
|
||||
<p className="text-sm text-destructive">{errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p>Loading...</p>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { Button, Input, Label } from "@tensamin/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function Page() {
|
||||
const { save, load } = useStorage();
|
||||
const [draftOmegaUrl, setDraftOmegaUrl] = useState("");
|
||||
const [currentOmegaUrl, setCurrentOmegaUrl] = useState("");
|
||||
|
||||
const [draftForcedOmikronUrl, setDraftForcedOmikronUrl] = useState("");
|
||||
const [currentForcedOmikronUrl, setForcedForcedOmikronUrl] = useState("");
|
||||
const [draftForcedOmikronPublicKey, setDraftForcedOmikronPublicKey] =
|
||||
useState("");
|
||||
const [currentForcedOmikronPublicKey, setForcedForcedOmikronPublicKey] =
|
||||
useState("");
|
||||
|
||||
useEffect(() => {
|
||||
load("omega_url").then((value) => {
|
||||
setDraftOmegaUrl(value);
|
||||
setCurrentOmegaUrl(value);
|
||||
});
|
||||
load("forced_omikron_url").then((value) => {
|
||||
setDraftForcedOmikronUrl(value || "");
|
||||
setForcedForcedOmikronUrl(value || "");
|
||||
});
|
||||
load("forced_omikron_public_key").then((value) => {
|
||||
setDraftForcedOmikronPublicKey(value || "");
|
||||
setForcedForcedOmikronPublicKey(value || "");
|
||||
});
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<p className="text-destructive">
|
||||
It's best not to touch these! They can be exploited to gain access to
|
||||
your account!
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Omega Url</Label>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={draftOmegaUrl}
|
||||
onChange={(e) => setDraftOmegaUrl(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
disabled={currentOmegaUrl === draftOmegaUrl}
|
||||
onClick={() => {
|
||||
save("omega_url", draftOmegaUrl).then(() =>
|
||||
setCurrentOmegaUrl(draftOmegaUrl),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Forced Omikron</Label>
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
placeholder="URL..."
|
||||
value={draftForcedOmikronUrl || ""}
|
||||
onChange={(e) => setDraftForcedOmikronUrl(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Public Key..."
|
||||
value={draftForcedOmikronPublicKey || ""}
|
||||
onChange={(e) => setDraftForcedOmikronPublicKey(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
disabled={
|
||||
currentForcedOmikronUrl === draftForcedOmikronUrl &&
|
||||
currentForcedOmikronPublicKey === draftForcedOmikronPublicKey
|
||||
}
|
||||
onClick={() => {
|
||||
save("forced_omikron_url", draftForcedOmikronUrl).then(() =>
|
||||
setForcedForcedOmikronUrl(draftForcedOmikronUrl),
|
||||
);
|
||||
save(
|
||||
"forced_omikron_public_key",
|
||||
draftForcedOmikronPublicKey,
|
||||
).then(() =>
|
||||
setForcedForcedOmikronPublicKey(draftForcedOmikronPublicKey),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { StylePicker } from "@tensamin/ui";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="overflow-y-auto">
|
||||
<StylePicker />
|
||||
<div className="absolute bottom-0 right-0 mb-3 mr-2">
|
||||
<a
|
||||
className="block w-60 text-xs whitespace-pre-wrap"
|
||||
href="https://git.methanium.net/tensamin/client/issues/new"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Please open a Git issue to help us improve this feature. We want to
|
||||
get it right.
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -85,6 +85,7 @@ export default defineConfig({
|
|||
"@tanstack/router-core",
|
||||
"@tanstack/store",
|
||||
"@tensamin/crypto",
|
||||
"@tensamin/settings",
|
||||
"@tensamin/storage",
|
||||
"@tensamin/mtp",
|
||||
"@tensamin/user",
|
||||
|
|
@ -138,6 +139,7 @@ export default defineConfig({
|
|||
"@tensamin/shared",
|
||||
"@tensamin/shared/data",
|
||||
"@tensamin/shared/log",
|
||||
"@tensamin/settings",
|
||||
"@tensamin/storage",
|
||||
"@tensamin/storage/context",
|
||||
"@tensamin/tauri",
|
||||
|
|
|
|||
Loading…
Reference in a new issue