chore: v2.8.15 — Oh My Pi login sharing, omp --allow-home, Markdown Preview default, first-login password

- omp-share.js: an admin's switch (S3) runs omp's own auth broker + gateway as
  the host on loopback; a member's omp pane reaches it as a scoped client keyed
  by its pane token (clients file = live omp panes only), runs on
  --model agenthub/<model>, never holds a credential.
- omp catalog: --allow-home, so a workspace at the account's home keeps its
  sessions there (they were recorded under a temp dir and dropped).
- Settings → Appearance → Open Markdown as: Preview (default) or Raw, for
  Files/Changes, the config viewer and a chat's file panel.
- First-login password change and password rules, avatar URLs, Artifacts
  "Mine" includes installed shared skills/plugins, a member's own hook pages.
This commit is contained in:
2026-10-22 10:04:30 +07:00
parent 8a6dc6a29e
commit 750d74a2af
66 changed files with 3021 additions and 696 deletions
@@ -3,10 +3,12 @@
* @description Settings → Account (server mode, any signed-in role): the
* account's own information (`GET /api/me/account` — username, role, member
* since, workspaces, sessions, active sign-ins, last sign-in), its avatar
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB; the
* sidebar shows it at once), its display name
* (`PUT`/`DELETE /api/me/avatar` — PNG, JPEG, GIF or WebP, at most 1 MB — or
* an https link the server downloads, `POST /api/me/avatar/url`; the sidebar
* shows it at once), its display name
* and email (`PATCH /api/me/account`), a password change that asks for the
* current one (`POST /api/me/password` — this browser stays signed in, every
* current one and passes every rule of `lib/passwordRules.ts`, ticked as it is
* typed (`POST /api/me/password` — this browser stays signed in, every
* other sign-in ends) and "Sign out other devices" (`POST
* /api/me/sign-out-others`, behind `confirmDialog`). Role and state are an
* admin's to change, on the Users page — never here.
@@ -23,7 +25,7 @@ import {
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { Camera, LogOut, RefreshCw, Trash2 } from "lucide-react";
import { Camera, Link2, LogOut, RefreshCw, Trash2 } from "lucide-react";
import { api, type MyAccount } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import { toast } from "../../lib/toast";
@@ -32,6 +34,8 @@ import { isSubmitEnter } from "../../lib/keys";
import { useAuth } from "../AuthGate";
import { ROLE_BADGE } from "../users/UserEditDialog";
import { UserAvatar } from "../users/UserAvatar";
import { PasswordRules } from "../users/PasswordRules";
import { passwordProblems } from "../../lib/passwordRules";
import { SettingsSection } from "./SettingsRow";
type ServerAccount = Extract<MyAccount, { mode: "server" }>;
@@ -110,18 +114,27 @@ function ProfileCard({
const shown = user.displayName || user.username;
const fileRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
// null = the link field is closed.
const [link, setLink] = useState<string | null>(null);
const run = async (op: () => Promise<MyAccount>, done: string) => {
setBusy(true);
try {
onSaved(await op());
toast.success(done);
return true;
} catch (e) {
toast.error(errText(t, e));
return false;
} finally {
setBusy(false);
}
};
const applyLink = async () => {
const url = link?.trim();
if (!url) return;
if (await run(() => api.me.setAvatarFromUrl(url), t("account.avatar.saved"))) setLink(null);
};
const pick = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ""; // the same file can be picked again
@@ -150,6 +163,18 @@ function ProfileCard({
>
<Camera className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={`btn-ghost h-6 w-6 justify-center p-0 ${link !== null ? "text-accent" : ""}`}
onClick={() => setLink((v) => (v === null ? "" : null))}
disabled={busy}
title={t("account.avatar.fromUrl")}
aria-label={t("account.avatar.fromUrl")}
aria-pressed={link !== null}
data-testid="account-avatar-url-toggle"
>
<Link2 className="h-3.5 w-3.5" />
</button>
{user.avatarAt && (
<button
type="button"
@@ -187,6 +212,36 @@ function ProfileCard({
</div>
</div>
</div>
{link !== null && (
<div className="mt-3 flex items-center gap-2">
<input
type="url"
className="input w-full text-sm"
value={link}
placeholder={t("account.avatar.urlPlaceholder")}
aria-label={t("account.avatar.fromUrl")}
autoFocus
disabled={busy}
onChange={(e) => setLink(e.target.value)}
onKeyDown={(e) => {
if (isSubmitEnter(e)) {
e.preventDefault();
void applyLink();
} else if (e.key === "Escape") setLink(null);
}}
data-testid="account-avatar-url-input"
/>
<button
type="button"
className="btn-primary flex-shrink-0 text-sm"
onClick={() => void applyLink()}
disabled={busy || !link.trim()}
data-testid="account-avatar-url-apply"
>
{t("account.avatar.urlApply")}
</button>
</div>
)}
<dl className="mt-4 grid grid-cols-2 gap-3 border-t border-border pt-4 text-xs sm:grid-cols-5">
<Stat label={t("account.memberSince")}>
{user.createdAt ? formatDateTime(user.createdAt) : "—"}
@@ -283,13 +338,17 @@ function ProfileForm({
function PasswordForm() {
const { t } = useTranslation("settings");
const { state } = useAuth();
const username = state.mode === "server" ? state.user?.username : undefined;
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [again, setAgain] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const mismatch = again.length > 0 && again !== next;
const ready = current.length > 0 && next.length > 0 && again === next && !busy;
// The account's own password: every rule (server/lib/auth.js decides too).
const strong = passwordProblems(next, username).length === 0;
const ready = current.length > 0 && strong && again === next && !busy;
const submit = async () => {
if (!ready) return;
@@ -331,7 +390,7 @@ function PasswordForm() {
aria-label={t("account.currentPassword")}
/>
</Field>
<Field label={t("account.newPassword")} hint={t("users:passwordHint")}>
<Field label={t("account.newPassword")}>
<input
type="password"
className="input w-full text-sm"
@@ -352,6 +411,9 @@ function PasswordForm() {
/>
</Field>
</div>
<div className="mt-3">
<PasswordRules password={next} username={username} />
</div>
{(mismatch || error) && (
<p role="alert" className="mt-2 text-xs text-status-danger">
{error ?? t("account.mismatch")}