feat(backend): add user management
- no more static keys, full register/login/logout flow - add API error type - refactor API to centralize request calls - minor refactors re: vault options - CSS refactor (buttons don't require classes, input styling)
This commit is contained in:
parent
02bfe94f39
commit
3f5188df0d
13 changed files with 435 additions and 77 deletions
|
@ -413,7 +413,7 @@ async fn main() -> Result<()> {
|
|||
trust_executables: args.trust_executables,
|
||||
secret,
|
||||
},
|
||||
public: Arc::new(Mutex::new(upend.connection()?.get_users()?.len() == 0)),
|
||||
public: Arc::new(Mutex::new(upend.connection()?.get_users()?.is_empty())),
|
||||
};
|
||||
|
||||
// Start HTTP server
|
||||
|
|
|
@ -72,22 +72,40 @@ pub struct UserPayload {
|
|||
password: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginQueryParams {
|
||||
via: Option<String>,
|
||||
}
|
||||
|
||||
#[post("/api/auth/login")]
|
||||
pub async fn login(
|
||||
state: web::Data<State>,
|
||||
payload: web::Json<UserPayload>,
|
||||
query: web::Query<LoginQueryParams>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
let conn = state.upend.connection().map_err(ErrorInternalServerError)?;
|
||||
|
||||
match conn.authenticate_user(&payload.username, &payload.password) {
|
||||
Ok(()) => {
|
||||
let token = create_token(&payload.username, &state.config.secret)?;
|
||||
Ok(HttpResponse::Ok().json(json!({ "token": token })))
|
||||
match query.via.as_deref() {
|
||||
Some("cookie") => Ok(HttpResponse::NoContent()
|
||||
.append_header((http::header::SET_COOKIE, format!("key={}; Path=/", token)))
|
||||
.finish()),
|
||||
_ => Ok(HttpResponse::Ok().json(json!({ "key": token }))),
|
||||
}
|
||||
}
|
||||
Err(e) => Err(ErrorUnauthorized(e)),
|
||||
}
|
||||
}
|
||||
|
||||
#[post("/api/auth/logout")]
|
||||
pub async fn logout() -> Result<HttpResponse, Error> {
|
||||
Ok(HttpResponse::NoContent()
|
||||
.append_header((http::header::SET_COOKIE, "key=; Path=/; Max-Age=0"))
|
||||
.finish())
|
||||
}
|
||||
|
||||
#[post("/api/auth/register")]
|
||||
pub async fn register(
|
||||
req: HttpRequest,
|
||||
|
@ -108,27 +126,40 @@ pub async fn register(
|
|||
}
|
||||
}
|
||||
|
||||
#[get("/api/auth/whoami")]
|
||||
pub async fn whoami(req: HttpRequest, state: web::Data<State>) -> Result<HttpResponse, Error> {
|
||||
let user = check_auth(&req, &state)?;
|
||||
Ok(HttpResponse::Ok().json(json!({ "user": user })))
|
||||
}
|
||||
|
||||
fn check_auth(req: &HttpRequest, state: &State) -> Result<Option<String>, actix_web::Error> {
|
||||
if *state.public.lock().unwrap() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Some(auth_header) = req.headers().get("Authorization") {
|
||||
let auth_header = auth_header.to_str().map_err(|err| {
|
||||
let key = if let Some(value) = req.headers().get("Authorization") {
|
||||
let value = value.to_str().map_err(|err| {
|
||||
ErrorBadRequest(format!("Invalid value in Authorization header: {err:?}"))
|
||||
})?;
|
||||
|
||||
// decode Bearer
|
||||
if !auth_header.starts_with("Bearer ") {
|
||||
if !value.starts_with("Bearer ") {
|
||||
return Err(ErrorUnauthorized("Invalid token type."));
|
||||
}
|
||||
Some(value.trim_start_matches("Bearer ").to_string())
|
||||
} else if let Ok(cookies) = req.cookies() {
|
||||
cookies
|
||||
.iter()
|
||||
.find(|c| c.name() == "key")
|
||||
.map(|cookie| cookie.value().to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(key) = key {
|
||||
let token = jsonwebtoken::decode::<JwtClaims>(
|
||||
auth_header.trim_start_matches("Bearer "),
|
||||
&key,
|
||||
&jsonwebtoken::DecodingKey::from_secret(state.config.secret.as_ref()),
|
||||
&jsonwebtoken::Validation::default(),
|
||||
);
|
||||
|
||||
match token {
|
||||
Ok(token) => Ok(Some(token.claims.user)),
|
||||
Err(err) => Err(ErrorUnauthorized(format!("Invalid token: {err:?}"))),
|
||||
|
|
|
@ -47,6 +47,8 @@ where
|
|||
.wrap(actix_web::middleware::Logger::default().exclude("/api/jobs"))
|
||||
.service(routes::login)
|
||||
.service(routes::register)
|
||||
.service(routes::logout)
|
||||
.service(routes::whoami)
|
||||
.service(routes::get_raw)
|
||||
.service(routes::head_raw)
|
||||
.service(routes::get_thumbnail)
|
||||
|
|
|
@ -22,6 +22,11 @@ const dbg = debug("upend:api");
|
|||
|
||||
export type { AddressComponents };
|
||||
|
||||
export type UpendApiError = {
|
||||
kind: "Unauthorized" | "HttpError" | "FetchError" | "Unknown";
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export class UpEndApi {
|
||||
private instanceUrl = "";
|
||||
private readonly wasmExtensions: UpEndWasmExtensions | undefined = undefined;
|
||||
|
@ -29,15 +34,21 @@ export class UpEndApi {
|
|||
|
||||
private queryOnceLRU = new LRU<string, UpListing>({ max: 128 });
|
||||
private inFlightRequests: { [key: string]: Promise<UpListing> | null } = {};
|
||||
private key: string | undefined;
|
||||
private readonly onError: ((error: UpendApiError) => void) | undefined;
|
||||
|
||||
constructor(config: {
|
||||
instanceUrl?: string;
|
||||
wasmExtensions?: UpEndWasmExtensions;
|
||||
timeout?: number;
|
||||
authKey?: string;
|
||||
onError?: (error: UpendApiError) => void;
|
||||
}) {
|
||||
this.setInstanceUrl(config.instanceUrl || "http://localhost:8093");
|
||||
this.wasmExtensions = config.wasmExtensions;
|
||||
this.timeout = config.timeout || 30_000;
|
||||
this.key = config.authKey;
|
||||
this.onError = config.onError;
|
||||
}
|
||||
|
||||
public setInstanceUrl(apiUrl: string) {
|
||||
|
@ -53,10 +64,10 @@ export class UpEndApi {
|
|||
options?: ApiFetchOptions,
|
||||
): Promise<UpObject> {
|
||||
dbg("Fetching Entity %s", address);
|
||||
const signal = this.getAbortSignal(options);
|
||||
const entityFetch = await fetch(`${this.apiUrl}/obj/${address}`, {
|
||||
signal,
|
||||
});
|
||||
const entityFetch = await this.fetch(
|
||||
`${this.apiUrl}/obj/${address}`,
|
||||
options,
|
||||
);
|
||||
const entityResult = (await entityFetch.json()) as EntityListing;
|
||||
const entityListing = new UpListing(entityResult.entries);
|
||||
return entityListing.getObject(address);
|
||||
|
@ -64,8 +75,7 @@ export class UpEndApi {
|
|||
|
||||
public async fetchEntry(address: string, options?: ApiFetchOptions) {
|
||||
dbg("Fetching entry %s", address);
|
||||
const signal = this.getAbortSignal(options);
|
||||
const response = await fetch(`${this.apiUrl}/raw/${address}`, { signal });
|
||||
const response = await this.fetch(`${this.apiUrl}/raw/${address}`, options);
|
||||
const data = await response.json();
|
||||
const listing = new UpListing({ address: data });
|
||||
return listing.entries[0];
|
||||
|
@ -82,12 +92,10 @@ export class UpEndApi {
|
|||
if (!this.inFlightRequests[queryStr]) {
|
||||
dbg(`Querying: ${query}`);
|
||||
this.inFlightRequests[queryStr] = new Promise((resolve, reject) => {
|
||||
const signal = this.getAbortSignal(options);
|
||||
fetch(`${this.apiUrl}/query`, {
|
||||
this.fetch(`${this.apiUrl}/query`, options, {
|
||||
method: "POST",
|
||||
body: queryStr,
|
||||
keepalive: true,
|
||||
signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
|
@ -117,12 +125,10 @@ export class UpEndApi {
|
|||
options?: ApiFetchOptions,
|
||||
): Promise<PutResult> {
|
||||
dbg("Putting %O", input);
|
||||
const signal = this.getAbortSignal(options);
|
||||
const response = await fetch(`${this.apiUrl}/obj`, {
|
||||
method: "PUT",
|
||||
const response = await this.fetch(`${this.apiUrl}/obj`, options, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
method: "PUT",
|
||||
body: JSON.stringify(input),
|
||||
signal,
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
|
@ -141,12 +147,10 @@ export class UpEndApi {
|
|||
url += `?provenance=${provenance}`;
|
||||
}
|
||||
|
||||
const signal = this.getAbortSignal(options);
|
||||
const response = await fetch(url, {
|
||||
const response = await this.fetch(url, options, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(value),
|
||||
signal,
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
|
@ -203,10 +207,9 @@ export class UpEndApi {
|
|||
xhr.send(formData);
|
||||
});
|
||||
} else {
|
||||
const response = await fetch(`${this.apiUrl}/blob`, {
|
||||
const response = await this.fetch(`${this.apiUrl}/blob`, options, {
|
||||
method: "PUT",
|
||||
body: formData,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
@ -222,8 +225,9 @@ export class UpEndApi {
|
|||
options?: ApiFetchOptions,
|
||||
): Promise<void> {
|
||||
dbg("Deleting entry %s", address);
|
||||
const signal = this.getAbortSignal(options);
|
||||
await fetch(`${this.apiUrl}/obj/${address}`, { method: "DELETE", signal });
|
||||
await this.fetch(`${this.apiUrl}/obj/${address}`, options, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
public getRaw(address: Address, preview = false) {
|
||||
|
@ -236,26 +240,24 @@ export class UpEndApi {
|
|||
options?: ApiFetchOptions,
|
||||
) {
|
||||
dbg("Getting %s raw (preview = %s)", address, preview);
|
||||
const signal = this.getAbortSignal(options);
|
||||
return await fetch(this.getRaw(address, preview), { signal });
|
||||
return await this.fetch(this.getRaw(address, preview), options);
|
||||
}
|
||||
|
||||
public async refreshVault(options?: ApiFetchOptions) {
|
||||
dbg("Triggering vault refresh");
|
||||
const signal = this.getAbortSignal(options);
|
||||
return await fetch(`${this.apiUrl}/refresh`, { method: "POST", signal });
|
||||
return await this.fetch(`${this.apiUrl}/refresh`, options, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
public async nativeOpen(address: Address, options?: ApiFetchOptions) {
|
||||
dbg("Opening %s natively", address);
|
||||
const signal = this.getAbortSignal(options);
|
||||
return fetch(`${this.apiUrl}/raw/${address}?native=1`, { signal });
|
||||
return this.fetch(`${this.apiUrl}/raw/${address}?native=1`, options);
|
||||
}
|
||||
|
||||
public async fetchRoots(options?: ApiFetchOptions): Promise<ListingResult> {
|
||||
dbg("Fetching hierarchical roots...");
|
||||
const signal = this.getAbortSignal(options);
|
||||
const response = await fetch(`${this.apiUrl}/hier_roots`, { signal });
|
||||
const response = await this.fetch(`${this.apiUrl}/hier_roots`, options);
|
||||
const roots = await response.json();
|
||||
dbg("Hierarchical roots: %O", roots);
|
||||
return roots;
|
||||
|
@ -263,8 +265,7 @@ export class UpEndApi {
|
|||
|
||||
public async fetchJobs(options?: ApiFetchOptions): Promise<IJob[]> {
|
||||
// dbg("Fetching jobs...");
|
||||
const signal = this.getAbortSignal(options);
|
||||
const response = await fetch(`${this.apiUrl}/jobs`, { signal });
|
||||
const response = await this.fetch(`${this.apiUrl}/jobs`, options);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
|
@ -272,8 +273,7 @@ export class UpEndApi {
|
|||
options?: ApiFetchOptions,
|
||||
): Promise<AttributeListingResult> {
|
||||
dbg("Fetching all attributes...");
|
||||
const signal = this.getAbortSignal(options);
|
||||
const response = await fetch(`${this.apiUrl}/all/attributes`, { signal });
|
||||
const response = await this.fetch(`${this.apiUrl}/all/attributes`, options);
|
||||
const result = await response.json();
|
||||
dbg("All attributes: %O", result);
|
||||
return await result;
|
||||
|
@ -281,19 +281,25 @@ export class UpEndApi {
|
|||
|
||||
public async fetchInfo(options?: ApiFetchOptions): Promise<VaultInfo> {
|
||||
dbg("Fetching vault info...");
|
||||
const signal = this.getAbortSignal(options);
|
||||
const response = await fetch(`${this.apiUrl}/info`, { signal });
|
||||
const response = await this.fetch(`${this.apiUrl}/info`, options);
|
||||
const result = await response.json();
|
||||
dbg("Vault info: %O", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async fetchOptions(options?: ApiFetchOptions): Promise<VaultOptions> {
|
||||
dbg("Fetching vault options...");
|
||||
const response = await this.fetch(`${this.apiUrl}/options`, options);
|
||||
const result = await response.json();
|
||||
dbg("Vault options: %O", result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public async fetchStoreInfo(
|
||||
options?: ApiFetchOptions,
|
||||
): Promise<{ [key: string]: StoreInfo }> {
|
||||
dbg("Fetching store info...");
|
||||
const signal = this.getAbortSignal(options);
|
||||
const response = await fetch(`${this.apiUrl}/stats/store`, { signal });
|
||||
const response = await this.fetch(`${this.apiUrl}/stats/store`, options);
|
||||
const result = await response.json();
|
||||
dbg("Store info: %O");
|
||||
return await result;
|
||||
|
@ -309,16 +315,15 @@ export class UpEndApi {
|
|||
await this.wasmExtensions.init();
|
||||
return this.wasmExtensions.AddressTypeConstants[input];
|
||||
}
|
||||
const signal = this.getAbortSignal(options);
|
||||
response = await fetch(`${this.apiUrl}/address?type=${input}`, {
|
||||
signal,
|
||||
});
|
||||
response = await this.fetch(
|
||||
`${this.apiUrl}/address?type=${input}`,
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
if ("urlContent" in input) {
|
||||
const signal = this.getAbortSignal(options);
|
||||
response = await fetch(
|
||||
response = await this.fetch(
|
||||
`${this.apiUrl}/address?url_content=${input.urlContent}`,
|
||||
{ signal },
|
||||
options,
|
||||
);
|
||||
} else {
|
||||
throw new Error("Input cannot be empty.");
|
||||
|
@ -352,8 +357,7 @@ export class UpEndApi {
|
|||
public async getVaultOptions(
|
||||
options?: ApiFetchOptions,
|
||||
): Promise<VaultOptions> {
|
||||
const signal = this.getAbortSignal(options);
|
||||
const response = await fetch(`${this.apiUrl}/options`, { signal });
|
||||
const response = await this.fetch(`${this.apiUrl}/options`, options);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
|
@ -369,12 +373,10 @@ export class UpEndApi {
|
|||
payload["blob_mode"] = blob_mode;
|
||||
}
|
||||
|
||||
const signal = this.getAbortSignal(apiOptions);
|
||||
const response = await fetch(`${this.apiUrl}/options`, {
|
||||
const response = await this.fetch(`${this.apiUrl}/options`, apiOptions, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
@ -382,6 +384,80 @@ export class UpEndApi {
|
|||
}
|
||||
}
|
||||
|
||||
public async authenticate(
|
||||
credentials: {
|
||||
username: string;
|
||||
password: string;
|
||||
},
|
||||
mode: "key",
|
||||
options?: ApiFetchOptions,
|
||||
): Promise<{ key: string }>;
|
||||
public async authenticate(
|
||||
credentials: {
|
||||
username: string;
|
||||
password: string;
|
||||
},
|
||||
mode?: "cookie",
|
||||
options?: ApiFetchOptions,
|
||||
): Promise<void>;
|
||||
public async authenticate(
|
||||
credentials: {
|
||||
username: string;
|
||||
password: string;
|
||||
},
|
||||
mode: "key" | "cookie" | undefined,
|
||||
options?: ApiFetchOptions,
|
||||
): Promise<{ key: string } | void> {
|
||||
const via = mode || "cookie";
|
||||
const response = await this.fetch(
|
||||
`${this.apiUrl}/auth/login?via=${via}`,
|
||||
options,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(credentials),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw Error(await response.text());
|
||||
}
|
||||
|
||||
if (mode === "key") {
|
||||
const data = await response.json();
|
||||
if (!data.key) {
|
||||
throw Error("No key returned from server.");
|
||||
}
|
||||
this.key = data.key;
|
||||
return data.key;
|
||||
}
|
||||
}
|
||||
|
||||
public async authStatus(
|
||||
options?: ApiFetchOptions,
|
||||
): Promise<{ user: string } | undefined> {
|
||||
const response = await this.fetch(`${this.apiUrl}/auth/whoami`, options);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
public async resetAuth(mode: "key"): Promise<void>;
|
||||
public async resetAuth(
|
||||
mode?: "cookie",
|
||||
options?: ApiFetchOptions,
|
||||
): Promise<void>;
|
||||
public async resetAuth(
|
||||
mode?: "key" | "cookie",
|
||||
options?: ApiFetchOptions,
|
||||
): Promise<void> {
|
||||
if (mode === "key") {
|
||||
this.key = undefined;
|
||||
} else {
|
||||
await this.fetch(`${this.apiUrl}/auth/logout`, options, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private getAbortSignal(options: ApiFetchOptions | undefined) {
|
||||
const controller = options?.abortController || new AbortController();
|
||||
const timeout = options?.timeout || this.timeout;
|
||||
|
@ -390,6 +466,51 @@ export class UpEndApi {
|
|||
}
|
||||
return controller.signal;
|
||||
}
|
||||
|
||||
private async fetch(
|
||||
url: string,
|
||||
options: ApiFetchOptions | undefined,
|
||||
requestInit?: RequestInit & { headers?: Record<string, string> },
|
||||
): Promise<Response> {
|
||||
const signal = this.getAbortSignal(options);
|
||||
const headers = requestInit?.headers || {};
|
||||
if (this.key) {
|
||||
headers["Authorization"] = `Bearer ${this.key}`;
|
||||
}
|
||||
|
||||
let result: Response;
|
||||
let error: UpendApiError | undefined;
|
||||
try {
|
||||
result = await fetch(url, {
|
||||
...requestInit,
|
||||
signal,
|
||||
headers,
|
||||
});
|
||||
if (!result.ok) {
|
||||
if (result.status === 401) {
|
||||
error = { kind: "Unauthorized" };
|
||||
} else {
|
||||
error = {
|
||||
kind: "HttpError",
|
||||
error: new Error(
|
||||
`HTTP Error ${result.status}: ${result.statusText}`,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
error = { kind: "FetchError", error: e as Error };
|
||||
}
|
||||
|
||||
if (error) {
|
||||
if (this.onError) {
|
||||
this.onError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result!;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiFetchOptions {
|
||||
|
@ -398,6 +519,7 @@ export interface ApiFetchOptions {
|
|||
}
|
||||
|
||||
export type VaultBlobMode = "Flat" | "Mirror" | "Incoming";
|
||||
|
||||
export interface VaultOptions {
|
||||
blob_mode: VaultBlobMode;
|
||||
}
|
||||
|
|
|
@ -97,6 +97,7 @@ export interface VaultInfo {
|
|||
location: string;
|
||||
version: string;
|
||||
desktop: boolean;
|
||||
public: boolean;
|
||||
}
|
||||
|
||||
export interface StoreInfo {
|
||||
|
|
|
@ -1,6 +1,25 @@
|
|||
import { UpEndApi } from '@upnd/upend';
|
||||
import { UpEndWasmExtensionsWeb } from '@upnd/upend/wasm/web';
|
||||
import wasmURL from '@upnd/wasm-web/upend_wasm_bg.wasm?url';
|
||||
import { type StartStopNotifier, writable, type Writable } from 'svelte/store';
|
||||
|
||||
const wasm = new UpEndWasmExtensionsWeb(wasmURL);
|
||||
export default new UpEndApi({ instanceUrl: '/', wasmExtensions: wasm });
|
||||
const api = new UpEndApi({ instanceUrl: '/', wasmExtensions: wasm });
|
||||
export default api;
|
||||
|
||||
export const currentUser: Writable<string | undefined> = writable(
|
||||
undefined as string | undefined,
|
||||
((set) => {
|
||||
api.authStatus().then((result) => set(result?.user));
|
||||
}) as StartStopNotifier<string | undefined>
|
||||
);
|
||||
|
||||
export async function login(credentials: { username: string; password: string }) {
|
||||
await api.authenticate(credentials);
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await api.resetAuth();
|
||||
window.location.reload();
|
||||
}
|
||||
|
|
90
webui/src/lib/components/LoginModal.svelte
Normal file
90
webui/src/lib/components/LoginModal.svelte
Normal file
|
@ -0,0 +1,90 @@
|
|||
<script lang="ts">
|
||||
import { i18n } from '$lib/i18n';
|
||||
import Icon from '$lib/components/utils/Icon.svelte';
|
||||
import { login } from '$lib/api';
|
||||
|
||||
let username = '';
|
||||
let password = '';
|
||||
let error: string | undefined;
|
||||
let authenticating = false;
|
||||
|
||||
async function submit() {
|
||||
error = undefined;
|
||||
try {
|
||||
authenticating = true;
|
||||
await login({ username, password });
|
||||
} catch (e) {
|
||||
error = (e as object).toString();
|
||||
} finally {
|
||||
authenticating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="modal-container">
|
||||
<div class="modal" class:authenticating>
|
||||
<h2>
|
||||
<Icon name="lock" />
|
||||
{$i18n.t('Authorization required')}
|
||||
</h2>
|
||||
<form on:submit|preventDefault={submit}>
|
||||
<input placeholder={$i18n.t('Username')} type="text" bind:value={username} required />
|
||||
<input placeholder={$i18n.t('Password')} type="password" bind:value={password} required />
|
||||
<button type="submit"> <Icon plain name="log-in" /> {$i18n.t('Login')}</button>
|
||||
</form>
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '$lib/styles/colors';
|
||||
|
||||
.modal-container {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.66);
|
||||
color: var(--foreground);
|
||||
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--foreground);
|
||||
padding: 2rem;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
&.authenticating {
|
||||
filter: brightness(0.66);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
form {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: colors.$red;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
|
@ -2,13 +2,16 @@
|
|||
import { addEmitter } from '../AddModal.svelte';
|
||||
import Icon from '../utils/Icon.svelte';
|
||||
import { jobsEmitter } from './Jobs.svelte';
|
||||
import api from '$lib/api';
|
||||
import api, { currentUser, logout } from '$lib/api';
|
||||
import Selector, { type SelectorValue } from '../utils/Selector.svelte';
|
||||
import { i18n } from '$lib/i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { vaultInfo } from '$lib/util/info';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
let selector: Selector;
|
||||
let userDropdown = false;
|
||||
|
||||
let lastSearched: SelectorValue[] = [];
|
||||
|
||||
|
@ -57,6 +60,7 @@
|
|||
}
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
|
||||
function onFileChange() {
|
||||
if (fileInput.files?.length) {
|
||||
addEmitter.emit('files', Array.from(fileInput.files));
|
||||
|
@ -73,6 +77,12 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<svelte:body
|
||||
on:click={() => {
|
||||
userDropdown = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
<div class="header">
|
||||
<h1>
|
||||
<a href="/">
|
||||
|
@ -91,13 +101,31 @@
|
|||
<Icon name="search" slot="prefix" />
|
||||
</Selector>
|
||||
</div>
|
||||
<button class="button" on:click={() => addEmitter.emit('choose')}>
|
||||
<button on:click={() => addEmitter.emit('choose')}>
|
||||
<Icon name="upload" />
|
||||
<input type="file" multiple bind:this={fileInput} on:change={onFileChange} />
|
||||
</button>
|
||||
<button class="button" on:click={() => rescan()} title="Rescan vault">
|
||||
<button on:click={() => rescan()} title="Rescan vault">
|
||||
<Icon name="refresh" />
|
||||
</button>
|
||||
<button
|
||||
class="user"
|
||||
disabled={$vaultInfo?.public}
|
||||
on:click|stopPropagation={() => (userDropdown = true)}
|
||||
>
|
||||
<Icon name="user" />
|
||||
</button>
|
||||
{#if userDropdown}
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions a11y-click-events-have-key-events -->
|
||||
<div class="user-dropdown" transition:slide on:click|stopPropagation={() => {}}>
|
||||
<div class="user">
|
||||
<Icon plain name="user" />
|
||||
{$currentUser || '???'}
|
||||
</div>
|
||||
<hr />
|
||||
<button on:click={() => logout()}> <Icon name="log-out" />{$i18n.t('Log out')}</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -141,6 +169,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
.user-dropdown {
|
||||
background: var(--background);
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--foreground);
|
||||
padding: 0.5em;
|
||||
position: absolute;
|
||||
top: 3.5rem;
|
||||
right: 0.5rem;
|
||||
box-shadow: 0 0 0.5rem rgba(0, 0, 0, 0.5);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.name {
|
||||
display: none;
|
||||
|
|
|
@ -166,7 +166,12 @@
|
|||
}}
|
||||
/>
|
||||
<div class="icon">
|
||||
<IconButton name="trash" color="#dc322f" on:click={() => removeEntity(entity)} />
|
||||
<IconButton
|
||||
plain
|
||||
name="trash"
|
||||
color="#dc322f"
|
||||
on:click={() => removeEntity(entity)}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="object">
|
||||
|
@ -181,7 +186,12 @@
|
|||
/>
|
||||
</div>
|
||||
<div class="icon">
|
||||
<IconButton name="trash" color="#dc322f" on:click={() => removeEntity(entity)} />
|
||||
<IconButton
|
||||
plain
|
||||
name="trash"
|
||||
color="#dc322f"
|
||||
on:click={() => removeEntity(entity)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
|
@ -300,6 +310,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 0.25em;
|
||||
}
|
||||
|
||||
.add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
@ -22,6 +22,7 @@ select {
|
|||
font-size: 2em;
|
||||
}
|
||||
|
||||
button,
|
||||
.button {
|
||||
border: 1px solid var(--foreground);
|
||||
border-radius: 4px;
|
||||
|
@ -52,6 +53,23 @@ select {
|
|||
}
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='password'] {
|
||||
padding: 0.25em;
|
||||
|
||||
border: 1px solid var(--foreground-lighter);
|
||||
border-radius: 4px;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
|
||||
transition: box-shadow 0.25s;
|
||||
|
||||
&:focus {
|
||||
box-shadow: -1px -1px 2px 2px var(--primary);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mark-entity::first-letter,
|
||||
.mark-entity *::first-letter {
|
||||
color: color.scale(color.mix(colors.$base1, colors.$red), $saturation: -33%);
|
||||
|
|
|
@ -1,9 +1,22 @@
|
|||
import api from '$lib/api';
|
||||
import { readable } from 'svelte/store';
|
||||
import { readable, type Readable } from 'svelte/store';
|
||||
import type { VaultInfo } from '@upnd/upend/types';
|
||||
import type { VaultOptions } from '@upnd/upend/api';
|
||||
|
||||
export const vaultInfo = readable(undefined as VaultInfo | undefined, (set) => {
|
||||
export const vaultInfo: Readable<VaultInfo | undefined> = readable(
|
||||
undefined as VaultInfo | undefined,
|
||||
(set) => {
|
||||
api.fetchInfo().then(async (info: VaultInfo) => {
|
||||
set(info);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const vaultOptions: Readable<VaultOptions | undefined> = readable(
|
||||
undefined as VaultOptions | undefined,
|
||||
(set) => {
|
||||
api.fetchOptions().then(async (options: VaultOptions) => {
|
||||
set(options);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
|
|
@ -4,6 +4,17 @@
|
|||
import Footer from '$lib/components/layout/Footer.svelte';
|
||||
import DropPasteHandler from '$lib/components/DropPasteHandler.svelte';
|
||||
import AddModal from '$lib/components/AddModal.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { vaultInfo, vaultOptions } from '$lib/util/info';
|
||||
import LoginModal from '$lib/components/LoginModal.svelte';
|
||||
import { currentUser } from '$lib/api';
|
||||
|
||||
onMount(() => {
|
||||
if ($vaultOptions && !$vaultOptions.blob_mode) {
|
||||
goto('/setup');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Header />
|
||||
|
@ -13,4 +24,7 @@
|
|||
<Footer />
|
||||
|
||||
<AddModal />
|
||||
{#if $vaultInfo && !$vaultInfo.public && !$currentUser}
|
||||
<LoginModal />
|
||||
{/if}
|
||||
<DropPasteHandler />>
|
||||
|
|
|
@ -157,14 +157,6 @@
|
|||
}
|
||||
];
|
||||
|
||||
fetch('/api/options')
|
||||
.then((res) => res.json())
|
||||
.then((options) => {
|
||||
if (!options.blob_mode) {
|
||||
goto('/setup');
|
||||
}
|
||||
});
|
||||
|
||||
$: updateTitle($vaultInfo?.name || $i18n.t('Home') || 'Home');
|
||||
</script>
|
||||
|
||||
|
|
Loading…
Reference in a new issue