Major rebranding, i18n, dashboard, shared folders, role capabilities
- Rebrand: new app/agent icons, SKALD.md, warm "paper" CSS palette (terracotta accent, --radius tokens, WCAG contrast, reduced-motion), updated favicon, tray icon, skaldkonur asset - i18n: backend crate (i18n.rs, locale column, ui_locale config), frontend library (web/lib/i18n.js, I18nMixin, t(key)), translation files (web/i18n/), every component wired - Dashboard: <dashboard-page> replaces old home-page content; <app-copilot> becomes the landing page (full/dock layout modes) - Shared folders: API endpoints (shared_folders.rs), frontend page, can_write membership, container mount topology, user_fs routing - Role capabilities: new db table & authorization seam (data not enums), roles.attrs JSON for ui_mode / interface select - Setup: skald-setup prompts for language + password, sets ui_locale - General: components migrated to CSS variables, Lit conventions cleanup, connectors/catalog/marketplace/approval refactoring
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import { html, nothing } from 'lit';
|
||||
import { LightElement } from '../lib/base.js';
|
||||
import { t, I18nMixin, LOCALES, setLocale, getLocale } from '../lib/i18n.js';
|
||||
|
||||
export class ProfilePage extends LightElement {
|
||||
export class ProfilePage extends I18nMixin(LightElement) {
|
||||
|
||||
static get properties() {
|
||||
return {
|
||||
@@ -10,6 +11,8 @@ export class ProfilePage extends LightElement {
|
||||
_displayName: { state: true },
|
||||
_savingName: { state: true },
|
||||
_nameMsg: { state: true },
|
||||
_locale: { state: true },
|
||||
_localeMsg: { state: true },
|
||||
_pwCurrent: { state: true },
|
||||
_pwNew: { state: true },
|
||||
_pwConfirm: { state: true },
|
||||
@@ -25,6 +28,8 @@ export class ProfilePage extends LightElement {
|
||||
this._displayName = '';
|
||||
this._savingName = false;
|
||||
this._nameMsg = null;
|
||||
this._locale = '';
|
||||
this._localeMsg = null;
|
||||
this._pwCurrent = '';
|
||||
this._pwNew = '';
|
||||
this._pwConfirm = '';
|
||||
@@ -47,6 +52,7 @@ export class ProfilePage extends LightElement {
|
||||
if (res.ok) {
|
||||
this._me = await res.json();
|
||||
this._displayName = this._me.display_name ?? '';
|
||||
this._locale = this._me.locale ?? '';
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
@@ -62,7 +68,7 @@ export class ProfilePage extends LightElement {
|
||||
body: JSON.stringify({ display_name: this._displayName.trim() || null }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._nameMsg = { type: 'ok', text: 'Saved.' };
|
||||
this._nameMsg = { type: 'ok', text: t('profile.saved') };
|
||||
await this._load();
|
||||
} catch (e) {
|
||||
this._nameMsg = { type: 'err', text: e.message };
|
||||
@@ -71,15 +77,34 @@ export class ProfilePage extends LightElement {
|
||||
}
|
||||
}
|
||||
|
||||
// '' → back to the instance default; otherwise a concrete locale id.
|
||||
async _changeLocale(value) {
|
||||
this._localeMsg = null;
|
||||
try {
|
||||
const res = await fetch('/api/auth/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locale: value === '' ? null : value }),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._locale = value;
|
||||
// Apply immediately: the explicit choice, or the instance default when reset.
|
||||
setLocale(value === '' ? (this._me?.default_locale || 'en') : value);
|
||||
this._localeMsg = { type: 'ok', text: t('profile.saved') };
|
||||
} catch (e) {
|
||||
this._localeMsg = { type: 'err', text: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
async _savePassword() {
|
||||
if (this._savingPw) return;
|
||||
this._pwMsg = null;
|
||||
if (this._pwNew.length < 4) {
|
||||
this._pwMsg = { type: 'err', text: 'Password must be at least 4 characters.' };
|
||||
this._pwMsg = { type: 'err', text: t('profile.pw.short') };
|
||||
return;
|
||||
}
|
||||
if (this._pwNew !== this._pwConfirm) {
|
||||
this._pwMsg = { type: 'err', text: 'Passwords do not match.' };
|
||||
this._pwMsg = { type: 'err', text: t('profile.pw.mismatch') };
|
||||
return;
|
||||
}
|
||||
this._savingPw = true;
|
||||
@@ -93,7 +118,7 @@ export class ProfilePage extends LightElement {
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
this._pwMsg = { type: 'ok', text: 'Password changed.' };
|
||||
this._pwMsg = { type: 'ok', text: t('profile.pw.changed') };
|
||||
this._pwCurrent = '';
|
||||
this._pwNew = '';
|
||||
this._pwConfirm = '';
|
||||
@@ -107,71 +132,89 @@ export class ProfilePage extends LightElement {
|
||||
render() {
|
||||
if (!this._open) return nothing;
|
||||
const me = this._me;
|
||||
const defaultLabel = LOCALES.find(l => l.id === me?.default_locale)?.label ?? me?.default_locale ?? 'English';
|
||||
|
||||
return html`
|
||||
<div class="um-page" style="display:flex">
|
||||
<div class="um-header">
|
||||
<h2 class="um-title"><i class="bi bi-person-circle me-2"></i>Profile</h2>
|
||||
<h2 class="um-title"><i class="bi bi-person-circle me-2"></i>${t('profile.title')}</h2>
|
||||
</div>
|
||||
<div style="padding:0 24px 48px;max-width:480px">
|
||||
|
||||
${me ? html`
|
||||
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:8px">
|
||||
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:var(--radius-md)">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">Account</h6>
|
||||
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">${t('profile.account')}</h6>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" style="font-size:.82rem;font-weight:600">Username</label>
|
||||
<label class="form-label" style="font-size:.82rem;font-weight:600">${t('profile.username')}</label>
|
||||
<input class="form-control" .value=${me.username} disabled />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label" style="font-size:.82rem;font-weight:600">Role</label>
|
||||
<label class="form-label" style="font-size:.82rem;font-weight:600">${t('profile.role')}</label>
|
||||
<input class="form-control" .value=${me.role_id} disabled />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:8px">
|
||||
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:var(--radius-md)">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">Display name</h6>
|
||||
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">${t('profile.name')}</h6>
|
||||
<div class="mb-3">
|
||||
<input class="form-control" placeholder="Your name"
|
||||
<input class="form-control" placeholder=${t('profile.name.ph')}
|
||||
.value=${this._displayName}
|
||||
@input=${e => this._displayName = e.target.value} />
|
||||
</div>
|
||||
${this._nameMsg ? html`<div class="alert alert-${this._nameMsg.type === 'ok' ? 'success' : 'danger'} py-2" style="font-size:.82rem">${this._nameMsg.text}</div>` : nothing}
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._saveName()} ?disabled=${this._savingName}>
|
||||
${this._savingName ? 'Saving…' : 'Save'}
|
||||
${this._savingName ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:8px">
|
||||
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:var(--radius-md)">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">Change password</h6>
|
||||
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">${t('profile.language')}</h6>
|
||||
<div class="mb-3">
|
||||
<select class="form-select"
|
||||
.value=${this._locale}
|
||||
@change=${e => this._changeLocale(e.target.value)}>
|
||||
<option value="">${t('profile.language.default', { locale: defaultLabel })}</option>
|
||||
${LOCALES.map(l => html`
|
||||
<option value=${l.id} ?selected=${this._locale === l.id}>${l.label}</option>
|
||||
`)}
|
||||
</select>
|
||||
</div>
|
||||
${this._localeMsg ? html`<div class="alert alert-${this._localeMsg.type === 'ok' ? 'success' : 'danger'} py-2" style="font-size:.82rem">${this._localeMsg.text}</div>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4" style="background:var(--card-bg);border-color:var(--card-border);border-radius:var(--radius-md)">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title mb-3" style="font-size:.8rem;text-transform:uppercase;letter-spacing:.03em;color:var(--placeholder-color)">${t('profile.pw')}</h6>
|
||||
${me?.role_id === 'admin' || me?.encrypted ? html`
|
||||
<div class="mb-3">
|
||||
<label class="form-label" style="font-size:.82rem;font-weight:600">Current password</label>
|
||||
<label class="form-label" style="font-size:.82rem;font-weight:600">${t('profile.pw.current')}</label>
|
||||
<input type="password" class="form-control" autocomplete="current-password"
|
||||
.value=${this._pwCurrent}
|
||||
@input=${e => this._pwCurrent = e.target.value} />
|
||||
</div>
|
||||
` : nothing}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" style="font-size:.82rem;font-weight:600">New password</label>
|
||||
<label class="form-label" style="font-size:.82rem;font-weight:600">${t('profile.pw.new')}</label>
|
||||
<input type="password" class="form-control" autocomplete="new-password"
|
||||
.value=${this._pwNew}
|
||||
@input=${e => this._pwNew = e.target.value} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" style="font-size:.82rem;font-weight:600">Confirm new password</label>
|
||||
<label class="form-label" style="font-size:.82rem;font-weight:600">${t('profile.pw.confirm')}</label>
|
||||
<input type="password" class="form-control" autocomplete="new-password"
|
||||
.value=${this._pwConfirm}
|
||||
@input=${e => this._pwConfirm = e.target.value} />
|
||||
</div>
|
||||
${this._pwMsg ? html`<div class="alert alert-${this._pwMsg.type === 'ok' ? 'success' : 'danger'} py-2" style="font-size:.82rem">${this._pwMsg.text}</div>` : nothing}
|
||||
<button class="btn btn-sm btn-primary" @click=${() => this._savePassword()} ?disabled=${this._savingPw}>
|
||||
${this._savingPw ? 'Changing…' : 'Change password'}
|
||||
${this._savingPw ? t('common.saving') : t('profile.pw.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user