+
TOTP
+
{totpLive ? formatTotp(totpLive.code) : '------'}
Refresh in: {totpLive ? `${totpLive.remain}s` : '--'}
+
+
@@ -865,15 +999,17 @@ export default function VaultPage(props: VaultPageProps) {
const value = uri.decUri || uri.uri || '';
if (!value.trim()) return null;
return (
-
-
Website
-
+
+
Website
+
{value}
+
+
@@ -919,28 +1055,68 @@ export default function VaultPage(props: VaultPageProps) {
{selectedCipher.decNotes || ''}
- {(selectedCipher.fields || []).length > 0 && (
+ {(selectedCipher.fields || []).some((x) => parseFieldType(x.type) !== 3) && (
Custom Fields
- {(selectedCipher.fields || []).map((field, index) => (
-
- {field.decName || 'Field'}
- {field.decValue || ''}
-
- ))}
+ {(selectedCipher.fields || [])
+ .filter((x) => parseFieldType(x.type) !== 3)
+ .map((field, index) => {
+ const fieldType = parseFieldType(field.type);
+ const fieldName = field.decName || 'Field';
+ const rawValue = field.decValue || '';
+ const isHiddenVisible = !!hiddenFieldVisibleMap[index];
+ if (fieldType === 2) {
+ return (
+
+
{fieldName}
+
+
+
+
+
+ );
+ }
+ return (
+
+
{fieldName}
+
+ {fieldType === 1 && !isHiddenVisible ? maskSecret(rawValue) : rawValue}
+
+
+ {fieldType === 1 && (
+
+ )}
+
+
+
+ );
+ })}
)}
+ >
+ )}
>
)}
@@ -965,7 +1141,7 @@ export default function VaultPage(props: VaultPageProps) {
{
type: fieldType,
label: fieldLabel.trim(),
- value: fieldValue,
+ value: fieldType === 2 ? (toBooleanFieldValue(fieldValue) ? 'true' : 'false') : fieldValue,
},
]);
setFieldModalOpen(false);
@@ -995,10 +1171,21 @@ export default function VaultPage(props: VaultPageProps) {
Field Label
setFieldLabel((e.currentTarget as HTMLInputElement).value)} />
-
+ {fieldType === 2 ? (
+
+ ) : (
+
+ )}
+
+
void verifyReprompt()}
+ onCancel={() => {
+ setRepromptOpen(false);
+ setRepromptPassword('');
+ }}
+ >
+
+
>
);
}
+
+
diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts
index 29d7642..a8092e2 100644
--- a/webapp/src/lib/api.ts
+++ b/webapp/src/lib/api.ts
@@ -307,6 +307,30 @@ export async function setTotp(
}
}
+export async function verifyMasterPassword(
+ authedFetch: (input: string, init?: RequestInit) => Promise
,
+ masterPasswordHash: string
+): Promise {
+ const resp = await authedFetch('/api/accounts/verify-password', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ masterPasswordHash }),
+ });
+ if (!resp.ok) {
+ const body = await parseJson(resp);
+ throw new Error(body?.error_description || body?.error || 'Master password verify failed');
+ }
+}
+
+export async function getTotpStatus(
+ authedFetch: (input: string, init?: RequestInit) => Promise
+): Promise<{ enabled: boolean }> {
+ const resp = await authedFetch('/api/accounts/totp');
+ if (!resp.ok) throw new Error('Failed to load TOTP status');
+ const body = (await parseJson<{ enabled?: boolean }>(resp)) || {};
+ return { enabled: !!body.enabled };
+}
+
export async function listAdminUsers(authedFetch: (input: string, init?: RequestInit) => Promise): Promise {
const resp = await authedFetch('/api/admin/users');
if (!resp.ok) throw new Error('Failed to load users');
@@ -335,6 +359,11 @@ export async function revokeInvite(authedFetch: (input: string, init?: RequestIn
if (!resp.ok) throw new Error('Revoke invite failed');
}
+export async function deleteAllInvites(authedFetch: (input: string, init?: RequestInit) => Promise): Promise {
+ const resp = await authedFetch('/api/admin/invites', { method: 'DELETE' });
+ if (!resp.ok) throw new Error('Delete all invites failed');
+}
+
export async function setUserStatus(
authedFetch: (input: string, init?: RequestInit) => Promise,
userId: string,
@@ -424,6 +453,7 @@ export async function createCipher(
const payload: Record = {
type,
+ favorite: !!draft.favorite,
folderId: asNullable(draft.folderId),
reprompt: draft.reprompt ? 1 : 0,
name: await encryptTextValue(draft.name, enc, mac),
@@ -508,7 +538,7 @@ export async function updateCipher(
type,
key: keys.key,
folderId: asNullable(draft.folderId),
- favorite: !!cipher.favorite,
+ favorite: !!draft.favorite,
reprompt: draft.reprompt ? 1 : 0,
name: await encryptTextValue(draft.name, keys.enc, keys.mac),
notes: await encryptTextValue(draft.notes, keys.enc, keys.mac),
diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts
index 201e5d4..1b73958 100644
--- a/webapp/src/lib/types.ts
+++ b/webapp/src/lib/types.ts
@@ -138,6 +138,7 @@ export interface VaultDraftField {
export interface VaultDraft {
id?: string;
type: number;
+ favorite: boolean;
name: string;
folderId: string;
notes: string;
diff --git a/webapp/src/styles.css b/webapp/src/styles.css
index 8b4a895..3a2ffee 100644
--- a/webapp/src/styles.css
+++ b/webapp/src/styles.css
@@ -95,23 +95,33 @@ body,
border-color: #2f5fd8;
}
+.input:disabled {
+ background: #e2e8f0;
+ border-color: #cbd5e1;
+ color: #94a3b8;
+ cursor: not-allowed;
+}
+
.password-wrap {
position: relative;
}
.password-wrap .input {
- padding-right: 88px;
+ padding-right: 44px;
}
.eye-btn {
position: absolute;
- right: 42px;
+ right: 10px;
bottom: 9px;
width: 30px;
height: 30px;
border: none;
background: transparent;
cursor: pointer;
+ display: grid;
+ place-items: center;
+ color: #334155;
}
.btn {
@@ -122,6 +132,14 @@ body,
font-size: 15px;
font-weight: 700;
cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+}
+
+.btn-icon {
+ flex-shrink: 0;
}
.btn.full {
@@ -166,6 +184,13 @@ body,
background: #fff1f2;
}
+.btn:disabled {
+ background: #e2e8f0;
+ border-color: #cbd5e1;
+ color: #94a3b8;
+ cursor: not-allowed;
+}
+
.or {
text-align: center;
margin: 10px 0;
@@ -219,7 +244,7 @@ body,
}
.user-email {
- font-size: 13px;
+ font-size: 18px;
opacity: 0.9;
}
@@ -311,7 +336,7 @@ body,
border-bottom: 1px solid var(--line);
padding: 12px;
display: flex;
- align-items: center;
+ align-items: flex-start;
gap: 10px;
}
@@ -334,6 +359,7 @@ body,
background: transparent;
padding: 0;
display: flex;
+ align-items: flex-start;
gap: 10px;
text-align: left;
cursor: pointer;
@@ -345,6 +371,7 @@ body,
display: grid;
place-items: center;
flex-shrink: 0;
+ margin-top: 1px;
}
.list-icon {
@@ -354,7 +381,14 @@ body,
}
.list-icon-fallback {
- font-size: 20px;
+ display: grid;
+ place-items: center;
+ color: #64748b;
+}
+
+.list-icon-fallback svg {
+ width: 24px;
+ height: 24px;
}
.list-text {
@@ -400,6 +434,7 @@ body,
.kv-line {
display: flex;
justify-content: space-between;
+ align-items: center;
gap: 10px;
border-bottom: 1px solid #ecf0f5;
padding: 10px 0;
@@ -413,8 +448,43 @@ body,
color: #64748b;
}
+.kv-row {
+ display: grid;
+ grid-template-columns: 90px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 10px;
+ border-bottom: 1px solid #ecf0f5;
+ padding: 10px 0;
+}
+
+.kv-row:last-child {
+ border-bottom: none;
+}
+
+.kv-label {
+ color: #64748b;
+}
+
+.kv-main {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ justify-content: flex-start;
+ min-width: 0;
+}
+
+.kv-actions {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
.notes {
white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ word-break: break-word;
color: #334155;
min-height: 48px;
}
@@ -472,6 +542,12 @@ body,
flex-wrap: wrap;
}
+.muted-inline {
+ color: var(--muted);
+ align-self: center;
+ font-size: 14px;
+}
+
.create-menu-wrap {
position: relative;
}
@@ -505,11 +581,36 @@ body,
.uri-row {
display: grid;
- grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto auto;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 8px;
margin-bottom: 8px;
}
+.website-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.website-row .btn {
+ justify-self: start;
+ width: auto;
+}
+
+.cf-check {
+ margin-bottom: 0;
+}
+
+.cf-check.view {
+ margin: 0;
+}
+
+.cf-check input[type='checkbox'] {
+ width: 22px;
+ height: 22px;
+}
+
.field-type-pill {
align-self: center;
height: 34px;
@@ -522,6 +623,10 @@ body,
padding: 0 10px;
}
+.star-on {
+ background: #eef4ff;
+}
+
.detail-actions {
display: flex;
justify-content: space-between;
@@ -535,6 +640,12 @@ body,
font-weight: 600;
}
+.status-ok {
+ margin: 2px 0 10px 0;
+ color: #0f766e;
+ font-weight: 700;
+}
+
.kv-line strong {
overflow-wrap: anywhere;
}
@@ -568,6 +679,23 @@ body,
width: 120px;
}
+.invite-toolbar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ margin-bottom: 10px;
+}
+
+.invite-row-actions {
+ justify-content: flex-end;
+}
+
+.invite-actions-head {
+ text-align: right !important;
+}
+
.dialog-mask {
position: fixed;
inset: 0;