Compare commits

..

4 Commits

Author SHA1 Message Date
Tomáš Mládek 6ae20f9171 feat: add `user` to every Entry
ci/woodpecker/push/woodpecker Pipeline failed Details
(very ugly, lots of clones)
2024-04-02 21:08:39 +02:00
Tomáš Mládek 5771f32736 feat: 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)
2024-04-02 20:57:53 +02:00
Tomáš Mládek b480cf2e64 feat(backend): users with passwords 2024-04-02 20:57:53 +02:00
Tomáš Mládek 07c76423ac style(webui): contain COVERs in UpObject headers
ci/woodpecker/push/woodpecker Pipeline failed Details
2024-04-02 20:53:56 +02:00
33 changed files with 250 additions and 562 deletions

View File

@ -6,7 +6,7 @@ pipeline:
environment:
- FORCE_COLOR=1
- EARTHLY_EXEC_CMD="/bin/sh"
secrets: [ EARTHLY_CONFIGURATION ]
secrets: [EARTHLY_CONFIGURATION]
commands:
- mkdir ~/.earthly && echo "$EARTHLY_CONFIGURATION" > ~/.earthly/config.yaml
- earthly bootstrap
@ -19,7 +19,7 @@ pipeline:
environment:
- FORCE_COLOR=1
- EARTHLY_EXEC_CMD="/bin/sh"
secrets: [ EARTHLY_CONFIGURATION ]
secrets: [EARTHLY_CONFIGURATION]
commands:
- mkdir ~/.earthly && echo "$EARTHLY_CONFIGURATION" > ~/.earthly/config.yaml
- earthly bootstrap
@ -52,14 +52,13 @@ pipeline:
SSH_CONFIG,
SSH_UPLOAD_KEY,
SSH_KNOWN_HOSTS,
SENTRY_AUTH_TOKEN
]
commands:
- mkdir ~/.earthly && echo "$EARTHLY_CONFIGURATION" > ~/.earthly/config.yaml
- earthly bootstrap
- earthly --secret GPG_SIGN_KEY --secret SSH_CONFIG --secret SSH_UPLOAD_KEY --secret SSH_KNOWN_HOSTS +deploy-appimage-nightly
when:
branch: [ main ]
branch: [main]
docker:nightly:
image: earthly/earthly:v0.8.3
@ -68,7 +67,7 @@ pipeline:
environment:
- FORCE_COLOR=1
- EARTHLY_EXEC_CMD="/bin/sh"
secrets: [ EARTHLY_CONFIGURATION, DOCKER_USER, DOCKER_PASSWORD, SENTRY_AUTH_TOKEN ]
secrets: [EARTHLY_CONFIGURATION, DOCKER_USER, DOCKER_PASSWORD]
commands:
- echo $${DOCKER_PASSWORD}| docker login --username $${DOCKER_USER} --password-stdin
- mkdir ~/.earthly && echo "$EARTHLY_CONFIGURATION" > ~/.earthly/config.yaml
@ -76,7 +75,7 @@ pipeline:
- earthly --push +docker-minimal
- earthly --push +docker
when:
branch: [ main ]
branch: [main]
docker:release:
image: earthly/earthly:v0.8.3
@ -85,7 +84,7 @@ pipeline:
environment:
- FORCE_COLOR=1
- EARTHLY_EXEC_CMD="/bin/sh"
secrets: [ EARTHLY_CONFIGURATION, DOCKER_USER, DOCKER_PASSWORD, SENTRY_AUTH_TOKEN ]
secrets: [EARTHLY_CONFIGURATION, DOCKER_USER, DOCKER_PASSWORD]
commands:
- echo $${DOCKER_PASSWORD}| docker login --username $${DOCKER_USER} --password-stdin
- mkdir ~/.earthly && echo "$EARTHLY_CONFIGURATION" > ~/.earthly/config.yaml
@ -95,7 +94,7 @@ pipeline:
- earthly --strict --push +docker --tag=latest
- earthly --strict --push +docker --tag=$CI_COMMIT_TAG
when:
event: [ tag ]
event: [tag]
jslib:publish:
image: earthly/earthly:v0.8.3
@ -104,13 +103,13 @@ pipeline:
environment:
- FORCE_COLOR=1
- EARTHLY_EXEC_CMD="/bin/sh"
secrets: [ EARTHLY_CONFIGURATION, NPM_TOKEN ]
secrets: [EARTHLY_CONFIGURATION, NPM_TOKEN]
commands:
- mkdir ~/.earthly && echo "$EARTHLY_CONFIGURATION" > ~/.earthly/config.yaml
- earthly bootstrap
- earthly --strict --push --secret NPM_TOKEN +publish-js-all
when:
branch: [ main ]
branch: [main]
gitea:prerelease:
image: earthly/earthly:v0.8.3
@ -127,7 +126,7 @@ pipeline:
- rm -rf dist
when:
event: [ tag ]
appimage:release:
image: earthly/earthly:v0.8.3
volumes:
@ -135,15 +134,15 @@ pipeline:
environment:
- FORCE_COLOR=1
- EARTHLY_EXEC_CMD="/bin/sh"
secrets: [ EARTHLY_CONFIGURATION, REGISTRY, REGISTRY_USER, REGISTRY_PASSWORD, SENTRY_AUTH_TOKEN ]
secrets: [ EARTHLY_CONFIGURATION, REGISTRY, REGISTRY_USER, REGISTRY_PASSWORD ]
commands:
- mkdir ~/.earthly && echo "$EARTHLY_CONFIGURATION" > ~/.earthly/config.yaml
- earthly bootstrap
- mkdir -p dist/
- earthly --strict -a '+appimage-signed/*' dist/
when:
event: [ tag ]
event: [tag]
# todo: webext
gitea:release:
@ -158,4 +157,4 @@ pipeline:
target: main
note: CHANGELOG_CURRENT.md
when:
event: [ tag ]
event: [tag]

View File

@ -96,7 +96,7 @@ pub async fn login(
_ => Ok(HttpResponse::Ok().json(json!({ "key": token }))),
}
}
Err(_) => Err(ErrorUnauthorized("Invalid credentials.")),
Err(e) => Err(ErrorUnauthorized(e)),
}
}
@ -138,32 +138,22 @@ fn check_auth(req: &HttpRequest, state: &State) -> Result<Option<String>, actix_
return Ok(None);
}
let header_key = req.headers().get("Authorization").and_then(|value| {
value.to_str().ok().and_then(|value| {
if value.starts_with("Bearer ") {
Some(value.trim_start_matches("Bearer ").to_string())
} else {
None
}
})
});
let cookie_key = req.cookies().ok().and_then(|cookies| {
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:?}"))
})?;
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())
});
let query_key = req.query_string().split('&').find_map(|pair| {
let parts = pair.split('=').collect::<Vec<&str>>();
match parts[..] {
["auth_key", value] => Some(value.to_string()),
_ => None,
}
});
let key = header_key.or(cookie_key).or(query_key);
} else {
None
};
if let Some(key) = key {
let token = jsonwebtoken::decode::<JwtClaims>(

View File

@ -314,21 +314,13 @@ impl UpEndConnection {
.filter(dsl::username.eq(username))
.load::<models::UserValue>(&conn)?;
match user_result.first() {
Some(user) => {
let parsed_hash = PasswordHash::new(&user.password).map_err(|e| anyhow!(e))?;
let argon2 = Argon2::default();
argon2
.verify_password(password.as_ref(), &parsed_hash)
.map_err(|e| anyhow!(e))
}
None => {
let argon2 = Argon2::default();
let _ = argon2
.verify_password(password.as_ref(), &PasswordHash::new(&DUMMY_HASH).unwrap());
Err(anyhow!("user not found"))
}
}
let user = user_result.first().ok_or(anyhow!("User not found"))?;
let parsed_hash = PasswordHash::new(&user.password).map_err(|e| anyhow!(e))?;
let argon2 = Argon2::default();
argon2
.verify_password(password.as_ref(), &parsed_hash)
.map_err(|e| anyhow!(e))
}
pub fn retrieve_entry(&self, hash: &UpMultihash) -> Result<Option<Entry>> {
@ -546,16 +538,6 @@ impl UpEndConnection {
}
}
lazy_static! {
static ref DUMMY_HASH: String = Argon2::default()
.hash_password(
"password".as_ref(),
&password_hash::SaltString::generate(&mut password_hash::rand_core::OsRng)
)
.unwrap()
.to_string();
}
#[cfg(test)]
mod test {
use upend_base::constants::{ATTR_IN, ATTR_LABEL};

View File

@ -1,6 +1,6 @@
{
"name": "@upnd/upend",
"version": "0.5.5",
"version": "0.5.0",
"description": "Client library to interact with the UpEnd system.",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@ -24,7 +24,6 @@ export type { AddressComponents };
export type UpendApiError = {
kind: "Unauthorized" | "HttpError" | "FetchError" | "Unknown";
message?: string;
error?: Error;
};
@ -38,18 +37,18 @@ export class UpEndApi {
private key: string | undefined;
private readonly onError: ((error: UpendApiError) => void) | undefined;
constructor(config?: {
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;
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) {
@ -231,15 +230,8 @@ export class UpEndApi {
});
}
public getRaw(
address: Address,
config?: { preview?: boolean; authenticated?: boolean },
) {
let result = `${this.apiUrl}/${config?.preview ? "thumb" : "raw"}/${address}`;
if (config?.authenticated) {
result += `?auth_key=${this.key}`;
}
return result;
public getRaw(address: Address, preview = false) {
return `${this.apiUrl}/${preview ? "thumb" : "raw"}/${address}`;
}
public async fetchRaw(
@ -248,7 +240,7 @@ export class UpEndApi {
options?: ApiFetchOptions,
) {
dbg("Getting %s raw (preview = %s)", address, preview);
return await this.fetch(this.getRaw(address, { preview }), options);
return await this.fetch(this.getRaw(address, preview), options);
}
public async refreshVault(options?: ApiFetchOptions) {
@ -441,17 +433,6 @@ export class UpEndApi {
}
}
public async register(credentials: {
username: string;
password: string;
}): Promise<void> {
await this.fetch(`${this.apiUrl}/auth/register`, undefined, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(credentials),
});
}
public async authStatus(
options?: ApiFetchOptions,
): Promise<{ user: string } | undefined> {
@ -481,10 +462,7 @@ export class UpEndApi {
const controller = options?.abortController || new AbortController();
const timeout = options?.timeout || this.timeout;
if (timeout > 0) {
setTimeout(() => {
dbg("Aborting request after %d ms", timeout);
controller.abort();
}, timeout);
setTimeout(() => controller.abort(), timeout);
}
return controller.signal;
}
@ -510,11 +488,13 @@ export class UpEndApi {
});
if (!result.ok) {
if (result.status === 401) {
error = { kind: "Unauthorized", message: await result.text() };
error = { kind: "Unauthorized" };
} else {
error = {
kind: "HttpError",
message: `HTTP Error ${result.status}: ${result.statusText}`,
error: new Error(
`HTTP Error ${result.status}: ${result.statusText}`,
),
};
}
}

View File

@ -14,7 +14,7 @@ Sentry.init({
replaysOnErrorSampleRate: 1.0,
// If you don't want to use Session Replay, just remove the line below:
integrations: [replayIntegration(), Sentry.feedbackIntegration({ colorScheme: 'dark' })],
integrations: [replayIntegration()],
enabled: process.env.NODE_ENV !== 'development'
});

View File

@ -2,7 +2,6 @@ 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';
import * as Sentry from '@sentry/sveltekit';
const wasm = new UpEndWasmExtensionsWeb(wasmURL);
const api = new UpEndApi({ instanceUrl: '/', wasmExtensions: wasm });
@ -11,10 +10,7 @@ export default api;
export const currentUser: Writable<string | undefined> = writable(
undefined as string | undefined,
((set) => {
api.authStatus().then((result) => {
set(result?.user);
Sentry.setUser({ id: result?.user });
});
api.authStatus().then((result) => set(result?.user));
}) as StartStopNotifier<string | undefined>
);

View File

@ -1,12 +1,10 @@
<script context="module" lang="ts">
import mitt from 'mitt';
import type { Address } from '@upnd/upend/types';
export type AddEvents = {
choose: void;
files: File[];
urls: string[];
destination: Address;
};
export const addEmitter = mitt<AddEvents>();
</script>
@ -19,23 +17,18 @@
import { goto } from '$app/navigation';
import { i18n } from '$lib/i18n';
import { selected } from '$lib/components/EntitySelect.svelte';
import Modal from '$lib/components/layout/Modal.svelte';
import Selector, { type SelectorValue } from '$lib/components/utils/Selector.svelte';
import { ATTR_IN } from '@upnd/upend/constants';
let files: File[] = [];
let URLs: string[] = [];
let uploading = false;
let abortController: AbortController | undefined;
let destination: Address | undefined;
let progress: Record<string, number> = {};
let totalProgress: number | undefined;
let filesElement: HTMLDivElement;
$: visible = files.length + URLs.length > 0 || destination;
$: visible = files.length + URLs.length > 0;
addEmitter.on('files', (ev) => {
ev.forEach((file) => {
@ -46,18 +39,6 @@
});
});
addEmitter.on('destination', (ev) => {
destination = ev;
});
function onDestinationSelected(ev: CustomEvent<SelectorValue | undefined>) {
if (ev.detail?.t === 'Address') {
destination = ev.detail.c;
} else {
destination = undefined;
}
}
async function upload() {
uploading = true;
@ -77,16 +58,6 @@
},
timeout: -1
});
if (destination) {
await api.putEntry({
entity: address,
attribute: ATTR_IN,
value: {
t: 'Address',
c: destination
}
});
}
addresses.push(address);
if (!uploading) {
@ -119,7 +90,6 @@
URLs = [];
progress = {};
uploading = false;
destination = undefined;
}
function onKeydown(event: KeyboardEvent) {
@ -144,8 +114,9 @@
<svelte:window on:beforeunload={onBeforeUnload} />
<svelte:body on:keydown={onKeydown} />
{#if visible}
<Modal on:close={reset}>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="addmodal-container" class:visible class:uploading on:click={reset}>
<div class="addmodal" on:click|stopPropagation>
<div class="files" bind:this={filesElement}>
{#each files as file}
<div class="entry">
@ -183,28 +154,17 @@
{/if}
</div>
<div class="controls">
<div class="controls-destination">
<div class="label"><Icon plain name="download" /> {$i18n.t('Destination')}</div>
<Selector
initial={destination ? { t: 'Address', c: destination } : undefined}
types={['Address', 'NewAddress']}
placeholder={$i18n.t('Choose automatically') || ''}
on:input={onDestinationSelected}
/>
</div>
<div class="controls-submit">
<IconButton small disabled={uploading} name="upload" on:click={upload}>
{$i18n.t('Upload')}
</IconButton>
</div>
<IconButton small disabled={uploading} name="upload" on:click={upload}>
{$i18n.t('Upload')}
</IconButton>
</div>
{#if uploading}
<div class="progress">
<ProgressBar value={totalProgress} />
</div>
{/if}
</Modal>
{/if}
</div>
</div>
<style lang="scss">
.addmodal-container {
@ -300,23 +260,8 @@
.controls {
display: flex;
justify-content: center;
align-items: center;
font-size: 3em;
margin-top: 0.5rem;
gap: 1rem;
}
.controls-destination {
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 1rem;
flex-grow: 3;
}
.controls-submit {
margin: 0 1rem;
}
.progress {

View File

@ -6,7 +6,6 @@
import LabelBorder from './utils/LabelBorder.svelte';
import { createEventDispatcher } from 'svelte';
import { type Address } from '@upnd/upend/types';
import Icon from '$lib/components/utils/Icon.svelte';
const dispatch = createEventDispatcher<{
highlighted: string | undefined;
add: Address;
@ -17,7 +16,6 @@
export let hide = false;
export let header = '';
export let icon: string | null = null;
export let confirmRemoveMessage: string | null = $i18n.t('Are you sure you want to remove this?');
export let emptyMessage = $i18n.t('Nothing to show.');
@ -26,8 +24,8 @@
$: if (adding && selector) selector.focus();
async function add(ev: CustomEvent<SelectorValue | undefined>) {
if (ev.detail?.t !== 'Address') {
async function add(ev: CustomEvent<SelectorValue>) {
if (ev.detail.t !== 'Address') {
return;
}
dispatch('add', ev.detail.c);
@ -41,10 +39,7 @@
</script>
<LabelBorder {hide}>
<span slot="header"
>{#if icon}<Icon plain name={icon} />
{/if}{header}</span
>
<span slot="header">{header}</span>
{#if adding}
<div class="selector">

View File

@ -90,14 +90,14 @@
{#if group}
{#if icon}
<div class="icon">
<Icon plain name={icon} />
<Icon name={icon} />
</div>
{/if}
<UpObject link address={group} labels={title ? [title] : undefined} />
{:else}
{#if icon}
<div class="icon">
<Icon plain name={icon} />
<Icon name={icon} />
</div>
{/if}
{title || ''}

View File

@ -22,7 +22,6 @@
import { debug } from 'debug';
import { Any } from '@upnd/upend/query';
import { isDefined } from '$lib/util/werk';
import Icon from '$lib/components/utils/Icon.svelte';
const dbg = debug('kestrel:Inspect');
const dispatch = createEventDispatcher<{
@ -193,7 +192,7 @@
const allAttributes = (
await Promise.all(
($entity?.attr[`~${ATTR_OF}`] ?? []).map(async (e) => {
return { address: e.address, components: await api.addressToComponents(e.entity) };
return { address: e.entity, components: await api.addressToComponents(e.entity) };
})
)
)
@ -444,7 +443,6 @@
{#if currentUntypedProperties.length > 0}
<EntryView
title={$i18n.t('Other Properties') || ''}
icon="shape-triangle"
widgets={attributeWidgets}
entries={currentUntypedProperties}
on:change={onChange}
@ -455,7 +453,6 @@
{#if currentUntypedLinks.length > 0}
<EntryView
title={$i18n.t('Links') || ''}
icon="right-arrow-circle"
widgets={linkWidgets}
entries={currentUntypedLinks}
on:change={onChange}
@ -466,7 +463,6 @@
{#if !correctlyTagged || !incorrectlyTagged}
<EntryView
title={`${$i18n.t('Members')}`}
icon="link"
widgets={taggedWidgets}
entries={tagged}
on:change={onChange}
@ -475,7 +471,6 @@
{:else}
<EntryView
title={`${$i18n.t('Typed Members')} (${correctlyTagged.length})`}
icon="link"
widgets={taggedWidgets}
entries={tagged.filter((e) => correctlyTagged?.includes(e.entity))}
on:change={onChange}
@ -483,7 +478,6 @@
/>
<EntryView
title={`${$i18n.t('Untyped members')} (${incorrectlyTagged.length})`}
icon="unlink"
widgets={taggedWidgets}
entries={tagged.filter((e) => incorrectlyTagged?.includes(e.entity))}
on:change={onChange}
@ -494,7 +488,6 @@
{#if currentBacklinks.length > 0}
<EntryView
title={`${$i18n.t('Referred to')} (${currentBacklinks.length})`}
icon="left-arrow-circle"
entries={currentBacklinks}
on:change={onChange}
{address}
@ -503,10 +496,7 @@
{#if $entityInfo?.t === 'Attribute'}
<LabelBorder>
<span slot="header">
<Icon plain name="color" />
{$i18n.t('Used')} ({attributesUsed.length})
</span>
<span slot="header">{$i18n.t('Used')} ({attributesUsed.length})</span>
<EntryList columns="entity,value" entries={attributesUsed} orderByValue />
</LabelBorder>
{/if}

View File

@ -39,7 +39,6 @@
<EntitySetEditor
entities={Object.keys(groups)}
header={$i18n.t('Groups') || ''}
icon="link-alt"
hide={Object.keys(groups).length === 0}
on:add={(e) => addGroup(e.detail)}
on:remove={(e) => removeGroup(e.detail)}

View File

@ -10,8 +10,6 @@
import { ATTR_OF } from '@upnd/upend/constants';
import { createEventDispatcher } from 'svelte';
import LabelBorder from './utils/LabelBorder.svelte';
import Icon from '$lib/components/utils/Icon.svelte';
const dispatch = createEventDispatcher();
export let entity: Readable<UpObject | undefined>;
@ -23,7 +21,6 @@
let types: Array<{ address: string; entry: UpEntry; required: UpEntry | undefined }> = [];
$: updateTypes($entity?.attr[`~${ATTR_OF}`] || []);
async function updateTypes(entries: UpEntry[]) {
types = [];
const query = await api.query(
@ -50,8 +47,8 @@
});
}
async function add(ev: CustomEvent<SelectorValue | undefined>) {
if (!$entity || ev.detail?.t !== 'Attribute') {
async function add(ev: CustomEvent<SelectorValue>) {
if (!$entity || ev.detail.t !== 'Attribute') {
return;
}
@ -101,7 +98,7 @@
{#if types.length || $entity?.attr['~IN']?.length}
<LabelBorder hide={types.length === 0}>
<span slot="header"><Icon plain name="list-check" /> {$i18n.t('Type Attributes')}</span>
<span slot="header">{$i18n.t('Type Attributes')}</span>
{#if adding}
<div class="selector">
<Selector
@ -125,11 +122,7 @@
class:required={type.required}
on:click={() => setRequired(type.entry, !type.required)}
>
{#if type.required}
<Icon plain name="lock" /> {$i18n.t('Required')}
{:else}
<Icon plain name="lock-open" /> {$i18n.t('Optional')}
{/if}
{type.required ? $i18n.t('Required') : $i18n.t('Optional')}
</button>
<div class="controls">
<IconButton name="x-circle" on:click={() => remove(type.entry)} />

View File

@ -2,8 +2,6 @@
import { i18n } from '$lib/i18n';
import Icon from '$lib/components/utils/Icon.svelte';
import { login } from '$lib/api';
import Modal from '$lib/components/layout/Modal.svelte';
import { type UpendApiError } from '@upnd/upend/api';
let username = '';
let password = '';
@ -16,43 +14,66 @@
authenticating = true;
await login({ username, password });
} catch (e) {
error = (e as UpendApiError).message || (e as UpendApiError).kind;
error = (e as object).toString();
} finally {
authenticating = false;
}
}
</script>
<Modal disabled={authenticating}>
<h2>
<Icon name="lock" />
{$i18n.t('Authorization required')}
</h2>
<form on:submit|preventDefault={submit}>
<input
name="username"
placeholder={$i18n.t('Username')}
type="text"
bind:value={username}
required
/>
<input
name="password"
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}
</Modal>
<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;

View File

@ -229,9 +229,9 @@
resizeObserver.observe(viewEl as any);
});
async function onSelectorInput(ev: CustomEvent<SelectorValue | undefined>) {
async function onSelectorInput(ev: CustomEvent<SelectorValue>) {
const value = ev.detail;
if (value?.t !== 'Address') return;
if (value.t !== 'Address') return;
const address = value.c;
const [xValue, yValue] = selectorCoords as any;
@ -261,7 +261,7 @@
types={['Attribute', 'NewAttribute']}
initial={x ? { t: 'Attribute', name: x } : undefined}
on:input={(ev) => {
if (ev.detail?.t === 'Attribute') x = ev.detail.name;
if (ev.detail.t === 'Attribute') x = ev.detail.name;
}}
/>
<div class="value">
@ -277,7 +277,7 @@
types={['Attribute', 'NewAttribute']}
initial={y ? { t: 'Attribute', name: y } : undefined}
on:input={(ev) => {
if (ev.detail?.t === 'Attribute') y = ev.detail.name;
if (ev.detail.t === 'Attribute') y = ev.detail.name;
}}
/>
<div class="value">

View File

@ -191,17 +191,24 @@
{/if}
<HashBadge {address} />
<div class="label" class:resolving title={displayLabel}>
{#if banner && !link}
<Editable value={{ t: 'String', c: displayLabel }} types={['String']} on:edit={onLabelEdit}>
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
</Editable>
{:else if link}
<UpLink passthrough to={{ entity: address }}>
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
</UpLink>
{:else}
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
{/if}
<Editable
value={{ t: 'String', c: displayLabel }}
editable={banner}
types={['String']}
on:edit={onLabelEdit}
>
<div class="label-inner">
{#if banner && hasFile}
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
{:else if link}
<UpLink to={{ entity: address }}>
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
</UpLink>
{:else}
<UpObjectLabel label={displayLabel} backpath={resolvedBackpath} />
{/if}
</div>
</Editable>
{#if $entity?.get(ATTR_KEY) && !$entity?.get(ATTR_KEY)?.toString()?.startsWith('TYPE_')}
<div class="key">{$entity.get(ATTR_KEY)}</div>
{/if}
@ -307,17 +314,10 @@
}
.label {
flex-grow: 1;
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: baseline;
margin-left: 0.25em;
:global(a) {
text-decoration: none;
}
}
.label-inner {
@ -351,8 +351,8 @@
}
&:not(.banner) .key {
display: inline-block;
margin-left: 0.5em;
flex-grow: 1;
text-align: right;
}
&.show-type .secondary,
@ -384,6 +384,15 @@
}
}
.label {
flex-grow: 1;
min-width: 0;
:global(a) {
text-decoration: none;
}
}
.icon {
margin: 0 0.1em;
}
@ -393,7 +402,6 @@
}
.link-button {
padding: 0.25em;
opacity: 0.66;
transition:
opacity 0.2s,

View File

@ -22,7 +22,7 @@
</div>
{/if}
<div class="label">
<UpObject {address} {labels} {banner} {select} link on:resolved />
<UpObject {address} {labels} {banner} {select} on:resolved />
</div>
</div>
</UpLink>

View File

@ -24,8 +24,7 @@
<style lang="scss">
.upobject-label {
flex-grow: 1;
min-width: 0;
max-width: 100%;
}
.backpath {

View File

@ -2,7 +2,7 @@
import Icon from '../utils/Icon.svelte';
import Jobs from './Jobs.svelte';
import Notifications from './Notifications.svelte';
import { i18n } from '$lib/i18n';
import { i18n } from '../../i18n';
let hidden = true;
let activeJobs: number;
@ -20,8 +20,6 @@
on:keydown={(ev) => {
if (['Space', 'Enter'].includes(ev.key)) hidden = !hidden;
}}
role="button"
tabindex="-1"
>
<div class="info">
{#if activeJobs > 0}
@ -44,7 +42,6 @@
position: fixed;
bottom: 0;
width: 100%;
z-index: 9;
display: flex;
flex-direction: column;

View File

@ -2,13 +2,13 @@
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 HeaderUserDropdown from '$lib/components/layout/HeaderUserDropdown.svelte';
import { slide } from 'svelte/transition';
let selector: Selector;
let userDropdown = false;
@ -28,7 +28,7 @@
lastSearched = lastSearched.slice(0, 10);
}
async function onInput(event: CustomEvent<SelectorValue | undefined>) {
async function onInput(event: CustomEvent<SelectorValue>) {
const value = event.detail;
if (!value) return;
@ -77,6 +77,12 @@
}
</script>
<svelte:body
on:click={() => {
userDropdown = false;
}}
/>
<div class="header">
<h1>
<a href="/">
@ -109,7 +115,17 @@
>
<Icon name="user" />
</button>
<HeaderUserDropdown bind:open={userDropdown} />
{#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">
@ -153,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;

View File

@ -1,140 +0,0 @@
<script lang="ts">
import api, { currentUser, logout } from '$lib/api';
import { type UpendApiError } from '@upnd/upend/api';
import { i18n } from '$lib/i18n';
import Icon from '$lib/components/utils/Icon.svelte';
import { slide } from 'svelte/transition';
import Modal from '$lib/components/layout/Modal.svelte';
export let open = false;
let changePasswordModal = false;
let currentPassword = '';
let newPassword = '';
let newPasswordConfirm = '';
async function changePassword() {
try {
const result = await api.authenticate(
{ username: $currentUser!, password: currentPassword },
'key'
);
if (result) {
await api.register({ username: $currentUser!, password: newPassword });
alert($i18n.t('Password changed successfully').toString());
changePasswordModal = false;
}
} catch (e: unknown) {
alert(
$i18n.t('Error authenticating: {error}', { error: (e as UpendApiError).message }).toString()
);
}
}
</script>
<svelte:body
on:click={() => {
open = false;
}}
/>
{#if open}
<!-- 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>
<button on:click={() => (changePasswordModal = true)}>
<Icon name="lock" />{$i18n.t('Change password')}</button
>
<button on:click={() => logout()}> <Icon name="log-out" />{$i18n.t('Log out')}</button>
</div>
{/if}
{#if changePasswordModal}
<Modal on:close={() => (changePasswordModal = false)}>
<h2>
<Icon name="lock" />
{$i18n.t('Change password')}
</h2>
<form class="change-password">
<label>
<span>{$i18n.t('Current password')}</span>
<input type="password" bind:value={currentPassword} required />
</label>
<label>
<span>{$i18n.t('New password')}</span>
<input type="password" bind:value={newPassword} required />
</label>
<label>
<span>{$i18n.t('Confirm new password')}</span>
<input type="password" bind:value={newPasswordConfirm} required />
</label>
<button
type="submit"
on:click={() => changePassword()}
disabled={newPassword !== newPasswordConfirm || !newPassword}
>{$i18n.t('Change password')}</button
>
</form>
</Modal>
{/if}
<style>
.user-dropdown {
display: flex;
flex-direction: column;
gap: 0.5rem;
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;
}
.user {
font-weight: 500;
margin: 0 1rem;
}
button,
.user {
display: flex;
align-items: center;
gap: 0.5rem;
}
h2 {
text-align: center;
margin: 0 0 1rem 0;
}
.change-password {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
gap: 0.5rem 1rem;
& label {
display: contents;
& span {
text-align: right;
}
}
& button {
grid-column: span 2;
justify-content: center;
justify-self: center;
font-weight: 500;
margin-top: 1rem;
}
}
</style>

View File

@ -1,59 +0,0 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let disabled = false;
export let dismissable = true;
function close() {
if (!dismissable) return;
dispatch('close');
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') close();
}
</script>
<svelte:body on:keydown={onKeydown} />
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="modal-container" on:click={close} transition:fade={{ duration: 200 }}>
<div class="modal" class:disabled on:click|stopPropagation>
<slot />
</div>
</div>
<style>
.modal-container {
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.66);
z-index: 999;
}
.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;
&.disabled {
filter: brightness(0.66);
pointer-events: none;
}
}
</style>

View File

@ -20,7 +20,7 @@
$: if (editing && selector) selector.focus();
$: if (!focus && !hover) editing = false;
function onInput(ev: CustomEvent<SelectorValue | undefined>) {
function onInput(ev: CustomEvent<SelectorValue>) {
newValue = ev.detail;
selector.focus();
}

View File

@ -35,7 +35,6 @@
flex-direction: column;
align-items: center;
padding: 0.25em;
border: 0;
background: transparent;
cursor: pointer;

View File

@ -1,6 +1,4 @@
<script lang="ts">
import { slide } from 'svelte/transition';
export let hide = false;
let hidden = true;
</script>
@ -8,35 +6,28 @@
<section class="labelborder" class:hide class:hidden>
<header
on:click={() => {
if (hide) hidden = !hidden;
if (hide) {
hidden = !hidden;
}
}}
on:keydown={(ev) => {
if (['Space', 'Enter'].includes(ev.key) && hide) hidden = !hidden;
}}
role="button"
tabindex="0"
>
<slot name="header-full">
<h3>
<slot name="header" />
</h3>
<h3><slot name="header" /></h3>
</slot>
</header>
{#if !hide || !hidden}
<div class="content" transition:slide>
<slot />
</div>
{:else}
<div class="hidden-indicator" />
{/if}
<div class="content">
<slot />
</div>
</section>
<style>
<style lang="scss">
section.labelborder {
margin-top: 0.66rem;
transition: opacity 0.5s ease-in-out;
& header {
header {
display: flex;
align-items: end;
justify-content: space-between;
@ -45,24 +36,28 @@
padding-bottom: 0.33rem;
margin-bottom: 0.33rem;
& h3 {
h3 {
margin: 0;
}
}
&.hide {
& header {
header {
cursor: pointer;
}
}
&.hide.hidden {
opacity: 0.66;
}
transition: opacity 0.2s ease-in-out;
&.hidden {
opacity: 0.66;
& .hidden-indicator {
border-top: 1px solid var(--foreground);
margin-top: calc(-0.33rem + 2px);
header {
border-bottom-width: 0.5px;
}
.content {
display: none;
}
}
}
}
</style>

View File

@ -6,7 +6,6 @@
import LabelBorder from './LabelBorder.svelte';
import { i18n } from '$lib/i18n';
import { format } from 'date-fns';
import Icon from '$lib/components/utils/Icon.svelte';
const dispatch = createEventDispatcher<{ change: WidgetChange }>();
export let address: string;
@ -35,7 +34,7 @@
</script>
<LabelBorder hide={!notes?.length}>
<span slot="header"><Icon plain name="note" /> {$i18n.t('Notes')}</span>
<span slot="header">Notes</span>
<div class="notes" contenteditable on:input={onInput} bind:this={contentEl}>
{#each (notes || '\n').split('\n') as line, idx}
{#if idx > 0}<br />{/if}

View File

@ -93,10 +93,7 @@
import debug from 'debug';
import Spinner from './Spinner.svelte';
const dispatch = createEventDispatcher<{
input: SelectorValue | undefined;
focus: boolean;
}>();
const dispatch = createEventDispatcher();
const dbg = debug('kestrel:Selector');
let selectorEl: HTMLElement;

View File

@ -11,7 +11,6 @@
import Selector, { type SelectorValue } from '../utils/Selector.svelte';
import { createEventDispatcher } from 'svelte';
import type { WidgetChange } from '$lib/types/base';
import { addEmitter } from '$lib/components/AddModal.svelte';
import debug from 'debug';
const dispatch = createEventDispatcher();
const dbg = debug(`kestrel:EntityList`);
@ -124,7 +123,7 @@
$: if (adding && addSelector) addSelector.focus();
function addEntity(ev: CustomEvent<SelectorValue | undefined>) {
function addEntity(ev: CustomEvent<SelectorValue>) {
dbg('Adding entity', ev.detail);
const addAddress = ev.detail?.t == 'Address' ? ev.detail.c : undefined;
if (!addAddress) return;
@ -203,39 +202,26 @@
{#if address}
<div class="add">
{#if adding}
<div class="main">
<Selector
bind:this={addSelector}
placeholder={$i18n.t('Add or create an entry') || ''}
types={['Address', 'NewAddress']}
on:input={addEntity}
on:focus={(ev) => {
if (!ev.detail) {
adding = false;
}
}}
/>
</div>
<Selector
bind:this={addSelector}
placeholder={$i18n.t('Search database or paste an URL') || ''}
types={['Address', 'NewAddress']}
on:input={addEntity}
on:focus={(ev) => {
if (!ev.detail) {
adding = false;
}
}}
/>
{:else}
<div class="main">
<IconButton
name="plus-circle"
outline
subdued
on:click={() => {
adding = true;
}}
/>
</div>
{#if address}
<IconButton
outline
subdued
name="upload"
title={$i18n.t('Upload a file') || ''}
on:click={() => addEmitter.emit('destination', address || '')}
/>
{/if}
<IconButton
name="plus-circle"
outline
subdued
on:click={() => {
adding = true;
}}
/>
{/if}
</div>
{/if}
@ -332,13 +318,7 @@
.add {
display: flex;
gap: 0.5em;
& .main {
display: flex;
flex-direction: column;
flex-grow: 1;
}
flex-direction: column;
}
.entitylist.style-grid .add {

View File

@ -364,8 +364,7 @@
<div class="cell mark-attribute">
<Selector
types={['Attribute', 'NewAttribute']}
on:input={(ev) =>
(newEntryAttribute = ev.detail?.t === 'Attribute' ? ev.detail?.name : '')}
on:input={(ev) => (newEntryAttribute = ev.detail?.name)}
on:focus={(ev) => (addFocus = ev.detail)}
keepFocusOnSet
bind:this={newAttrSelector}
@ -428,7 +427,7 @@
.attr-action {
display: flex;
justify-content: end;
justify-content: center;
align-items: center;
}

View File

@ -27,4 +27,4 @@
{#if $vaultInfo && !$vaultInfo.public && !$currentUser}
<LoginModal />
{/if}
<DropPasteHandler />
<DropPasteHandler />>

View File

@ -15,6 +15,7 @@
let root: HTMLDivElement;
let identities: string[] = [];
$: addresses = $page.params.addresses.split(',');
let backgrounds: Record<string, string | undefined> = {};
function add(value: SelectorValue) {
if (value.t !== 'Address') return;
@ -97,6 +98,7 @@
{only}
on:close={() => close(index)}
on:detail={(ev) => onDetailChanged(index, ev)}
background="var(--background-lightest)"
>
<CombineColumn spec={address} on:close={() => close(index)} />
</BrowseColumn>
@ -109,6 +111,7 @@
close(index);
}}
on:detail={(ev) => onDetailChanged(index, ev)}
background="var(--background-lightest)"
>
<SelectedColumn />
</BrowseColumn>
@ -145,13 +148,20 @@
{address}
{index}
{only}
background={backgrounds[address]}
on:close={() => close(index)}
on:resolved={(ev) => onIdentified(index, ev)}
on:detail={(ev) => onDetailChanged(index, ev)}
on:combine={() => addCombine(address)}
let:detail
>
<Inspect {address} {detail} on:resolved on:close />
<Inspect
{address}
{detail}
on:resolved
on:close
on:background={(ev) => (backgrounds[address] = ev.detail)}
/>
</BrowseColumn>
{/if}
</div>

View File

@ -42,12 +42,6 @@ export const Link: Story = {
}
};
export const Keyed: Story = {
args: {
address: 'zb2rhmpmTFPxdhaxTQg5Ug3KHFU8DZNUPh8TaPY2v8UQVJbQk'
}
};
export const Banner: Story = {
args: {
banner: true
@ -61,13 +55,6 @@ export const BannerWithLabels: Story = {
}
};
export const KeyedBanner: Story = {
args: {
address: 'zb2rhmpmTFPxdhaxTQg5Ug3KHFU8DZNUPh8TaPY2v8UQVJbQk',
banner: true
}
};
export const Overflow: Story = {
args: {
labels: ['qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbnm'.repeat(3)]

View File

@ -8,8 +8,7 @@ export default defineConfig({
sentrySvelteKit({
sourceMapsUploadOptions: {
org: 'upend',
project: 'upend-kestrel',
authToken: process.env.SENTRY_AUTH_TOKEN
project: 'upend-kestrel'
}
}),
sveltekit()