chore(web): update about page and access token settings

This commit is contained in:
johnnyjoygh
2026-07-19 22:23:51 +08:00
parent f94697496b
commit 06ecef33bc
7 changed files with 616 additions and 110 deletions
+359
View File
@@ -0,0 +1,359 @@
#!/usr/bin/env bash
# Black-box smoke test for a Memos release image.
#
# By default, the script builds the current worktree as a local Docker image.
# Pass --candidate-image to test an image that has already been built.
set -Eeuo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
candidate_image="${MEMOS_SMOKE_CANDIDATE_IMAGE:-}"
previous_image="${MEMOS_SMOKE_PREVIOUS_IMAGE:-}"
keep_resources="${MEMOS_SMOKE_KEEP_RESOURCES:-0}"
usage() {
cat <<'EOF'
Usage: ./scripts/release_smoke_test.sh [options]
Runs fresh-install and previous-stable upgrade smoke tests against a Memos
Docker image. With no options, the current worktree is built and tested.
Options:
--candidate-image IMAGE Test an existing image instead of building locally.
--previous-image IMAGE Image used to seed the upgrade test. By default, the
latest stable Git tag before HEAD is used.
--keep-resources Keep containers and volumes after the test for debugging.
-h, --help Show this help text.
Environment equivalents:
MEMOS_SMOKE_CANDIDATE_IMAGE
MEMOS_SMOKE_PREVIOUS_IMAGE
MEMOS_SMOKE_KEEP_RESOURCES=1
Examples:
./scripts/release_smoke_test.sh
./scripts/release_smoke_test.sh \
--candidate-image memos-smoke:local \
--previous-image neosmemo/memos:0.29.1
EOF
}
log() {
printf '\n==> %s\n' "$*"
}
die() {
printf 'error: %s\n' "$*" >&2
exit 1
}
while (($# > 0)); do
case "$1" in
--candidate-image)
(($# >= 2)) || die "--candidate-image requires a value"
candidate_image="$2"
shift 2
;;
--previous-image)
(($# >= 2)) || die "--previous-image requires a value"
previous_image="$2"
shift 2
;;
--keep-resources)
keep_resources=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
usage >&2
die "unknown option: $1"
;;
esac
done
for command_name in curl docker git jq; do
command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required"
done
docker info >/dev/null 2>&1 || die "Docker is not running"
run_id="${GITHUB_RUN_ID:-local}-$(date +%s)-$$"
fresh_container="memos-smoke-fresh-$run_id"
upgrade_old_container="memos-smoke-upgrade-old-$run_id"
upgrade_new_container="memos-smoke-upgrade-new-$run_id"
fresh_volume="memos-smoke-fresh-$run_id"
upgrade_volume="memos-smoke-upgrade-$run_id"
temp_dir="$(mktemp -d)"
frontend_index="$REPO_ROOT/server/router/frontend/dist/index.html"
frontend_index_backup="$temp_dir/frontend-index.html"
frontend_index_existed=0
frontend_index_backed_up=0
restore_frontend_index() {
if [[ "$frontend_index_backed_up" != "1" ]]; then
return
fi
if [[ "$frontend_index_existed" == "1" ]]; then
cp "$frontend_index_backup" "$frontend_index"
else
rm -f "$frontend_index"
fi
frontend_index_backed_up=0
}
container_exists() {
docker container inspect "$1" >/dev/null 2>&1
}
cleanup() {
local status=$?
trap - EXIT
restore_frontend_index
if ((status != 0)); then
for container_name in "$fresh_container" "$upgrade_old_container" "$upgrade_new_container"; do
if container_exists "$container_name"; then
printf '\n--- docker logs: %s ---\n' "$container_name" >&2
docker logs "$container_name" >&2 || true
fi
done
fi
if [[ "$keep_resources" == "1" ]]; then
printf '\nKept smoke-test resources for debugging:\n'
printf ' containers: %s %s %s\n' "$fresh_container" "$upgrade_old_container" "$upgrade_new_container"
printf ' volumes: %s %s\n' "$fresh_volume" "$upgrade_volume"
else
docker rm -f "$fresh_container" "$upgrade_old_container" "$upgrade_new_container" >/dev/null 2>&1 || true
docker volume rm "$fresh_volume" "$upgrade_volume" >/dev/null 2>&1 || true
fi
rm -rf "$temp_dir"
exit "$status"
}
trap cleanup EXIT
detect_previous_image() {
local head_sha tag tag_sha
head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)"
while IFS= read -r tag; do
case "$tag" in
*-rc.*) continue ;;
esac
tag_sha="$(git -C "$REPO_ROOT" rev-list -n 1 "$tag")"
if [[ "$tag_sha" == "$head_sha" ]]; then
continue
fi
if git -C "$REPO_ROOT" merge-base --is-ancestor "$tag" HEAD; then
printf 'neosmemo/memos:%s\n' "${tag#v}"
return
fi
done < <(git -C "$REPO_ROOT" tag --list 'v[0-9]*' --sort=-version:refname)
die "could not detect a previous stable release; pass --previous-image"
}
build_local_candidate() {
local commit_sha image_tag
command -v pnpm >/dev/null 2>&1 || die "pnpm is required to build the local candidate"
commit_sha="$(git -C "$REPO_ROOT" rev-parse --short=12 HEAD)"
image_tag="memos-smoke:${commit_sha}-${run_id}"
mkdir -p "$(dirname "$frontend_index")"
if [[ -f "$frontend_index" ]]; then
cp "$frontend_index" "$frontend_index_backup"
frontend_index_existed=1
fi
frontend_index_backed_up=1
log "Building frontend release assets"
(
cd "$REPO_ROOT/web"
pnpm release
)
log "Building candidate image $image_tag"
docker build \
--file "$REPO_ROOT/scripts/Dockerfile" \
--build-arg VERSION=smoke-local \
--build-arg COMMIT="$commit_sha" \
--tag "$image_tag" \
"$REPO_ROOT"
restore_frontend_index
candidate_image="$image_tag"
}
ensure_image() {
local image="$1"
if docker image inspect "$image" >/dev/null 2>&1; then
return
fi
log "Pulling image $image"
docker pull "$image"
}
wait_until_ready() {
local container_name="$1"
local base_url="$2"
local attempt
for attempt in $(seq 1 90); do
if curl --fail --silent --show-error --max-time 2 "$base_url/healthz" >/dev/null 2>&1; then
return
fi
if [[ "$(docker inspect --format '{{.State.Running}}' "$container_name" 2>/dev/null || true)" != "true" ]]; then
break
fi
sleep 2
done
docker logs "$container_name" >&2 || true
die "$container_name did not become ready"
}
current_base_url=""
set_current_base_url() {
local container_name="$1"
local port_mapping port
port_mapping="$(docker port "$container_name" 5230/tcp)"
port="${port_mapping##*:}"
[[ -n "$port" ]] || die "could not determine the host port for $container_name"
current_base_url="http://127.0.0.1:$port"
}
start_container() {
local container_name="$1"
local image="$2"
local volume="$3"
docker run --detach \
--name "$container_name" \
--label "org.usememos.release-smoke=$run_id" \
--publish "127.0.0.1::5230" \
--env MEMOS_MODE=prod \
--env MEMOS_INSTANCE_URL=http://localhost \
--mount "type=volume,source=$volume,target=/var/opt/memos" \
"$image" >/dev/null
set_current_base_url "$container_name"
wait_until_ready "$container_name" "$current_base_url"
}
assert_frontend_assets() {
local html asset_path asset_url
html="$(curl --fail --silent --show-error "$current_base_url/")"
grep -q 'id="root"' <<<"$html" || die "frontend root element was not served"
asset_path="$(grep -o 'src="[^"]*\.js"' <<<"$html" | sed -n '1{s/^src="//;s/"$//;p;}' || true)"
[[ -n "$asset_path" ]] || die "frontend JavaScript asset was not found"
case "$asset_path" in
http://*|https://*) asset_url="$asset_path" ;;
/*) asset_url="$current_base_url$asset_path" ;;
*) asset_url="$current_base_url/$asset_path" ;;
esac
curl --fail --silent --show-error "$asset_url" >/dev/null
}
create_admin() {
local payload response
payload="$(jq -nc '{username:"smoke-admin",password:"smoke-password",email:"smoke@example.test"}')"
response="$(curl --fail --silent --show-error \
--header 'Content-Type: application/json' \
--data "$payload" \
"$current_base_url/api/v1/users")"
jq -e '.username == "smoke-admin" and .role == "ADMIN"' <<<"$response" >/dev/null
}
sign_in() {
local payload
payload="$(jq -nc '{passwordCredentials:{username:"smoke-admin",password:"smoke-password"}}')"
curl --fail --silent --show-error \
--header 'Content-Type: application/json' \
--data "$payload" \
"$current_base_url/api/v1/auth/signin" | jq -er '.accessToken'
}
create_memo() {
local token="$1"
local memo_id="$2"
local content="$3"
local payload response
payload="$(jq -nc --arg content "$content" '{content:$content,visibility:"PRIVATE"}')"
response="$(curl --fail --silent --show-error \
--header "Authorization: Bearer $token" \
--header 'Content-Type: application/json' \
--data "$payload" \
"$current_base_url/api/v1/memos?memoId=$memo_id")"
jq -e --arg name "memos/$memo_id" --arg content "$content" '.name == $name and .content == $content' <<<"$response" >/dev/null
}
assert_memo() {
local token="$1"
local memo_id="$2"
local content="$3"
curl --fail --silent --show-error \
--header "Authorization: Bearer $token" \
"$current_base_url/api/v1/memos/$memo_id" |
jq -e --arg content "$content" '.content == $content' >/dev/null
}
if [[ -z "$candidate_image" ]]; then
build_local_candidate
else
ensure_image "$candidate_image"
fi
if [[ -z "$previous_image" ]]; then
previous_image="$(detect_previous_image)"
fi
ensure_image "$previous_image"
[[ "$candidate_image" != "$previous_image" ]] || die "candidate and previous images must be different"
log "Candidate image: $candidate_image"
log "Previous image: $previous_image"
log "Running fresh-install smoke test"
docker volume create "$fresh_volume" >/dev/null
start_container "$fresh_container" "$candidate_image" "$fresh_volume"
assert_frontend_assets
create_admin
fresh_token="$(sign_in)"
create_memo "$fresh_token" "release-smoke" "fresh install smoke sentinel"
docker restart "$fresh_container" >/dev/null
set_current_base_url "$fresh_container"
wait_until_ready "$fresh_container" "$current_base_url"
fresh_token="$(sign_in)"
assert_memo "$fresh_token" "release-smoke" "fresh install smoke sentinel"
docker rm -f "$fresh_container" >/dev/null
log "Running $previous_image to $candidate_image upgrade smoke test"
docker volume create "$upgrade_volume" >/dev/null
start_container "$upgrade_old_container" "$previous_image" "$upgrade_volume"
create_admin
upgrade_token="$(sign_in)"
create_memo "$upgrade_token" "pre-upgrade-smoke" "created before release upgrade"
docker rm -f "$upgrade_old_container" >/dev/null
start_container "$upgrade_new_container" "$candidate_image" "$upgrade_volume"
assert_frontend_assets
upgrade_token="$(sign_in)"
assert_memo "$upgrade_token" "pre-upgrade-smoke" "created before release upgrade"
create_memo "$upgrade_token" "post-upgrade-smoke" "created after release upgrade"
assert_memo "$upgrade_token" "post-upgrade-smoke" "created after release upgrade"
log "Release smoke tests passed"
@@ -1,6 +1,7 @@
import { BookmarkIcon } from "lucide-react";
import { useCallback, useState } from "react";
import { Link } from "react-router-dom";
import RelativeTime from "@/components/RelativeTime";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useNewMemo } from "@/contexts/NewMemoContext";
import useNavigateTo from "@/hooks/useNavigateTo";
@@ -36,7 +37,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({ showCreator, showVisibility, sh
const timeValue = isArchived ? (
memoDisplayTime?.toLocaleString(i18n.language)
) : (
<relative-time datetime={memoDisplayTime?.toISOString()} lang={i18n.language} format={relativeTimeFormat} no-title=""></relative-time>
<RelativeTime date={memoDisplayTime} format={relativeTimeFormat} />
);
const displayTime = isDisplayingUpdatedTime ? (
<>
+13
View File
@@ -0,0 +1,13 @@
import type { Format } from "@github/relative-time-element";
import i18n from "@/i18n";
interface RelativeTimeProps {
date?: Date;
format?: Format;
}
const RelativeTime = ({ date, format }: RelativeTimeProps) => (
<relative-time datetime={date?.toISOString()} lang={i18n.language} format={format} no-title=""></relative-time>
);
export default RelativeTime;
@@ -1,20 +1,22 @@
import { timestampDate } from "@bufbuild/protobuf/wkt";
import copy from "copy-to-clipboard";
import { CopyIcon, ExternalLinkIcon, PlusIcon, TrashIcon } from "lucide-react";
import { ChevronRightIcon, CopyIcon, ExternalLinkIcon, KeyRoundIcon, PlusIcon, ScissorsIcon, Trash2Icon } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "react-hot-toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import RelativeTime from "@/components/RelativeTime";
import { Button } from "@/components/ui/button";
import { userServiceClient } from "@/connect";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useDialog } from "@/hooks/useDialog";
import { WEB_CLIPPER_URL } from "@/lib/constants";
import { handleError } from "@/lib/error";
import { CreatePersonalAccessTokenResponse, PersonalAccessToken } from "@/types/proto/api/v1/user_service_pb";
import { useTranslate } from "@/utils/i18n";
import CreateAccessTokenDialog from "../CreateAccessTokenDialog";
import SettingGroup from "./SettingGroup";
import SettingSection from "./SettingSection";
import SettingTable from "./SettingTable";
const EXPIRING_SOON_MS = 30 * 24 * 60 * 60 * 1000;
const ApiUsageExample = () => {
const t = useTranslate();
@@ -26,7 +28,7 @@ const ApiUsageExample = () => {
};
return (
<div className="relative w-full min-w-0 rounded-lg border border-border/60 bg-background">
<div className="relative w-full min-w-0 rounded-lg border border-border/60 bg-muted/30">
<pre className="overflow-x-auto p-3 pr-12 font-mono text-xs leading-5 text-foreground/85">
<code>{example}</code>
</pre>
@@ -37,6 +39,24 @@ const ApiUsageExample = () => {
);
};
type TokenStatus = "active" | "expiring" | "idle";
const getTokenStatus = (lastUsedAt: Date | undefined, expiresAt: Date | undefined): TokenStatus => {
if (expiresAt && expiresAt.getTime() - Date.now() < EXPIRING_SOON_MS) {
return "expiring";
}
return lastUsedAt ? "active" : "idle";
};
const StatusDot = ({ status }: { status: TokenStatus }) => (
<span
aria-hidden="true"
className={`size-1.5 shrink-0 rounded-full ${
status === "active" ? "bg-success" : status === "expiring" ? "bg-warning" : "bg-muted-foreground/40"
}`}
/>
);
const listAccessTokens = async (parent: string) => {
const { personalAccessTokens } = await userServiceClient.listPersonalAccessTokens({ parent });
return personalAccessTokens.sort(
@@ -46,6 +66,140 @@ const listAccessTokens = async (parent: string) => {
);
};
const HowToUseDisclosure = () => {
const t = useTranslate();
const [open, setOpen] = useState(false);
return (
<div className="rounded-lg border border-border/60">
<button
type="button"
className="flex w-full items-center gap-1.5 px-3 py-2.5 text-[13px] font-medium text-muted-foreground hover:text-foreground"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<ChevronRightIcon className={`size-3.5 transition-transform ${open ? "rotate-90" : ""}`} />
{t("setting.access-token.how-to-use")}
</button>
{open && (
<div className="grid gap-4 border-t border-border/60 p-4 lg:grid-cols-2">
<div className="flex min-w-0 flex-col gap-2.5">
<p className="text-xs leading-5 text-muted-foreground">{t("setting.access-token.about-description")}</p>
<ApiUsageExample />
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
<a
className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-4 hover:text-primary hover:underline"
href="https://usememos.com/docs/security/access-tokens"
target="_blank"
rel="noreferrer"
>
{t("common.learn-more")}
<ExternalLinkIcon className="size-3" />
</a>
<a
className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-4 hover:text-primary hover:underline"
href={WEB_CLIPPER_URL}
target="_blank"
rel="noreferrer"
>
<ScissorsIcon className="size-3" />
{t("setting.access-token.web-clipper-title")}
<ExternalLinkIcon className="size-3" />
</a>
</div>
</div>
<ul className="m-0 flex list-none flex-col gap-1.5 p-0 text-xs leading-5 text-muted-foreground">
{[
t("setting.access-token.guideline-shown-once"),
t("setting.access-token.guideline-one-per-app"),
t("setting.access-token.guideline-expiration"),
t("setting.access-token.guideline-review"),
].map((guideline) => (
<li key={guideline} className="flex gap-2">
<span className="mt-[7px] size-1 shrink-0 rounded-full bg-muted-foreground/40" />
{guideline}
</li>
))}
</ul>
</div>
)}
</div>
);
};
const EmptyState = ({ onCreate }: { onCreate: () => void }) => {
const t = useTranslate();
return (
<div className="rounded-xl border border-dashed border-border px-6 py-10 text-center">
<span className="mx-auto flex size-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<KeyRoundIcon className="size-4" />
</span>
<h4 className="mt-3 text-sm font-medium text-foreground">{t("setting.access-token.empty-title")}</h4>
<p className="mx-auto mt-1 max-w-sm text-[13px] leading-5 text-muted-foreground">{t("setting.access-token.empty-description")}</p>
<div className="mx-auto mt-4 max-w-md text-left">
<ApiUsageExample />
</div>
<div className="mt-4 flex flex-wrap items-center justify-center gap-3">
<Button size="sm" onClick={onCreate}>
<PlusIcon className="w-4 h-4 mr-1.5" />
{t("setting.access-token.create-first")}
</Button>
<a
className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground"
href={WEB_CLIPPER_URL}
target="_blank"
rel="noreferrer"
>
<ScissorsIcon className="size-3.5" />
{t("setting.access-token.web-clipper-title")}
</a>
</div>
</div>
);
};
const TokenRow = ({ token, onDelete }: { token: PersonalAccessToken; onDelete: (token: PersonalAccessToken) => void }) => {
const t = useTranslate();
const lastUsedAt = token.lastUsedAt ? timestampDate(token.lastUsedAt) : undefined;
const expiresAt = token.expiresAt ? timestampDate(token.expiresAt) : undefined;
const status = getTokenStatus(lastUsedAt, expiresAt);
return (
<li className="group flex items-center gap-3 px-4 py-3">
<StatusDot status={status} />
<div className="min-w-0 flex-1">
<div className="text-[13px] font-medium text-foreground">{token.description}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{lastUsedAt ? (
<>
{t("setting.access-token.last-used")} <RelativeTime date={lastUsedAt} />
</>
) : (
t("setting.access-token.never-used")
)}
{" · "}
{expiresAt ? (
<span className={status === "expiring" ? "text-warning" : ""}>
{t("setting.access-token.expires")} <RelativeTime date={expiresAt} />
</span>
) : (
t("setting.access-token.no-expiration")
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
aria-label={t("common.delete")}
className="text-muted-foreground opacity-100 transition-opacity hover:text-destructive sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100"
onClick={() => onDelete(token)}
>
<Trash2Icon className="w-3.5 h-auto" />
</Button>
</li>
);
};
const AccessTokenSection = () => {
const t = useTranslate();
const currentUser = useCurrentUser();
@@ -87,10 +241,6 @@ const AccessTokenSection = () => {
);
};
const handleDeleteAccessToken = (token: PersonalAccessToken) => {
setDeleteTarget(token);
};
const confirmDeleteAccessToken = async () => {
if (!deleteTarget) return;
const { name: tokenName, description } = deleteTarget;
@@ -111,74 +261,18 @@ const AccessTokenSection = () => {
</Button>
}
>
<div className="grid w-full min-w-0 rounded-xl border border-border/60 bg-muted/20 lg:grid-cols-2">
<div className="flex min-w-0 flex-col gap-2.5 p-4 sm:p-5">
<h4 className="text-sm font-medium text-foreground">{t("setting.access-token.about-title")}</h4>
<p className="text-[13px] leading-6 text-muted-foreground">{t("setting.access-token.about-description")}</p>
<ApiUsageExample />
<a
className="inline-flex w-fit items-center gap-1 text-[13px] leading-5 text-muted-foreground underline-offset-4 hover:text-primary hover:underline"
href="https://usememos.com/docs/security/access-tokens"
target="_blank"
rel="noreferrer"
>
{t("common.learn-more")}
<ExternalLinkIcon className="size-3" />
</a>
</div>
<div className="flex min-w-0 flex-col gap-2.5 border-t border-border/60 p-4 sm:p-5 lg:border-t-0 lg:border-l">
<h4 className="text-sm font-medium text-foreground">{t("setting.access-token.guidelines-title")}</h4>
<ul className="flex list-disc flex-col gap-2 pl-4 text-[13px] leading-5 text-muted-foreground marker:text-muted-foreground/40">
<li>{t("setting.access-token.guideline-shown-once")}</li>
<li>{t("setting.access-token.guideline-one-per-app")}</li>
<li>{t("setting.access-token.guideline-expiration")}</li>
<li>{t("setting.access-token.guideline-review")}</li>
{personalAccessTokens.length === 0 ? (
<EmptyState onCreate={createTokenDialog.open} />
) : (
<>
<HowToUseDisclosure />
<ul className="m-0 flex list-none flex-col divide-y divide-border/60 rounded-xl border border-border/60 p-0">
{personalAccessTokens.map((token) => (
<TokenRow key={token.name} token={token} onDelete={setDeleteTarget} />
))}
</ul>
</div>
</div>
<SettingGroup title={t("setting.access-token.your-tokens")}>
<SettingTable
columns={[
{
key: "description",
header: t("common.description"),
render: (_, token: PersonalAccessToken) => <span className="text-foreground">{token.description}</span>,
},
{
key: "createdAt",
header: t("setting.access-token.create-dialog.created-at"),
render: (_, token: PersonalAccessToken) => (token.createdAt ? timestampDate(token.createdAt) : undefined)?.toLocaleString(),
},
{
key: "expiresAt",
header: t("setting.access-token.create-dialog.expires-at"),
render: (_, token: PersonalAccessToken) =>
(token.expiresAt ? timestampDate(token.expiresAt) : undefined)?.toLocaleString() ??
t("setting.access-token.create-dialog.duration-never"),
},
{
key: "lastUsedAt",
header: t("setting.access-token.last-used-at"),
render: (_, token: PersonalAccessToken) =>
(token.lastUsedAt ? timestampDate(token.lastUsedAt) : undefined)?.toLocaleString() ?? t("setting.access-token.never-used"),
},
{
key: "actions",
header: "",
className: "text-right",
render: (_, token: PersonalAccessToken) => (
<Button variant="ghost" size="sm" onClick={() => handleDeleteAccessToken(token)}>
<TrashIcon className="text-destructive w-4 h-auto" />
</Button>
),
},
]}
data={personalAccessTokens}
emptyMessage={t("setting.access-token.no-tokens-found")}
getRowKey={(token) => token.name}
/>
</SettingGroup>
</>
)}
{/* Create Access Token Dialog */}
<CreateAccessTokenDialog
+3
View File
@@ -7,3 +7,6 @@ export const DEFAULT_LIST_MEMOS_PAGE_SIZE = 16;
// LOADING_INDICATOR_DELAY_MS is how long a load must take before the loading spinner appears.
// Loads that finish faster than this never render the spinner, avoiding a flash on fast/self-hosted networks.
export const LOADING_INDICATOR_DELAY_MS = 250;
// Official companion browser extension for saving web content to Memos.
export const WEB_CLIPPER_URL = "https://github.com/usememos/web-clipper";
+17 -9
View File
@@ -1,11 +1,18 @@
{
"about": {
"blogs": "Blogs",
"build": "Build",
"commit": "Commit",
"description": "A privacy-first, lightweight note-taking service. Easily capture and share your great thoughts.",
"distribution": "Distribution",
"documents": "Documents",
"license": "License",
"media": "Media",
"github-repository": "GitHub Repo",
"official-website": "Official Website"
"official-website": "Official Website",
"powered-by": "Powered by Memos",
"project": "Project",
"web-clipper-platforms": "Chrome + Firefox"
},
"auth": {
"admin-sign-in": "Administrator sign-in",
@@ -437,31 +444,32 @@
"create-dialog": {
"access-token-created": "Access token `{{description}}` created",
"create-access-token": "Create Access Token",
"created-at": "Created At",
"description": "Description",
"duration-1m": "1 Month",
"duration-90d": "90 Days",
"duration-8h": "8 Hours",
"duration-never": "Never",
"expiration": "Expiration",
"expires-at": "Expires At",
"some-description": "Some description..."
},
"create-first": "Create your first token",
"description": "Create and revoke the secret keys that let other apps use the Memos API as you.",
"about-title": "What is a personal access token?",
"about-description": "A personal access token (PAT) is a secret key that authenticates API requests as your account. Any app or script holding one — an MCP server, a CLI, a mobile client — can do everything you can do in Memos, until the token expires or you delete it. Send it as a Bearer credential in the Authorization header:",
"guidelines-title": "Keep your tokens safe",
"empty-description": "A token lets a script or app call the API as you. It is shown once at creation, then listed here so you can revoke it.",
"empty-title": "No access tokens yet",
"expires": "Expires",
"guideline-shown-once": "A token is shown only once, right after you create it (it is copied to your clipboard). Store it somewhere safe, like a password manager.",
"guideline-one-per-app": "Create a separate token for each app or script, so you can revoke one without breaking the others.",
"guideline-expiration": "Prefer tokens that expire. Long-lived tokens are a bigger risk if they leak.",
"guideline-review": "Check the Last used column from time to time, and delete tokens you no longer recognize or need.",
"your-tokens": "Your tokens",
"guideline-review": "Check the last-used time from time to time, and delete tokens you no longer recognize or need.",
"how-to-use": "How to use a token",
"label": "Access Tokens",
"last-used-at": "Last Used",
"last-used": "Last used",
"never-used": "Never used",
"no-expiration": "No expiration",
"title": "Access Tokens",
"token": "Token",
"no-tokens-found": "No access tokens found"
"web-clipper-title": "Use a token with Memos Web Clipper"
},
"account": {
"change-password": "Change password",
+52 -24
View File
@@ -1,6 +1,7 @@
import { ExternalLinkIcon } from "lucide-react";
import { ExternalLinkIcon, ScissorsIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { useInstance } from "@/contexts/InstanceContext";
import { WEB_CLIPPER_URL } from "@/lib/constants";
import { useTranslate } from "@/utils/i18n";
const GITHUB_COMMIT_URL_PREFIX = "https://github.com/usememos/memos/commit/";
@@ -25,6 +26,10 @@ const Chip = ({ href, children }: { href?: string; children: React.ReactNode })
return <span className={className}>{children}</span>;
};
const SectionLabel = ({ children }: { children: React.ReactNode }) => (
<h2 className="text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground/55">{children}</h2>
);
const About = () => {
const t = useTranslate();
const { profile, generalSetting } = useInstance();
@@ -36,7 +41,7 @@ const About = () => {
const instanceLogo = customProfile?.logoUrl || DEFAULT_LOGO;
const isCustomBranded = instanceTitle !== DEFAULT_TITLE;
// Dev builds report version "dev" and commit "unknown"; show the raw version and skip the commit chip.
// Dev builds report version "dev" and commit "unknown"; show the raw version and skip the commit row.
const hasSemver = isSemver(profile.version);
const releaseUrl = hasSemver ? `${GITHUB_RELEASE_URL_PREFIX}${profile.version}` : "";
const versionLabel = hasSemver ? `v${profile.version}` : profile.version;
@@ -44,47 +49,70 @@ const About = () => {
const commitUrl = hasCommitSha ? `${GITHUB_COMMIT_URL_PREFIX}${profile.commit}` : "";
const shortCommit = hasCommitSha ? profile.commit.slice(0, 7) : "";
const buildRows: { label: string; value: React.ReactNode }[] = [];
if (profile.version) {
buildRows.push({ label: t("common.version"), value: <Chip href={releaseUrl || undefined}>{versionLabel}</Chip> });
}
if (shortCommit) {
buildRows.push({ label: t("about.commit"), value: <Chip href={commitUrl}>{shortCommit}</Chip> });
}
buildRows.push({ label: t("about.license"), value: <Chip href="https://github.com/usememos/memos/blob/main/LICENSE">MIT</Chip> });
if (isCustomBranded) {
buildRows.push({
label: t("about.distribution"),
value: <span className="text-[13px] text-muted-foreground">{t("about.powered-by")}</span>,
});
}
const projectLinks = [
{ label: t("about.official-website"), note: "the project homepage", href: "https://usememos.com/" },
{ label: t("about.documents"), note: "deploy, configure, use", href: "https://usememos.com/docs" },
{ label: "API Docs", note: "REST + gRPC reference", href: "https://usememos.com/docs/api" },
{ label: t("about.github-repository"), note: "source, issues, releases", href: "https://github.com/usememos/memos" },
{ label: "Web Clipper", note: t("about.web-clipper-platforms"), href: WEB_CLIPPER_URL, icon: ScissorsIcon },
];
return (
<section className="mx-auto w-full max-w-5xl min-h-full flex flex-col justify-start items-start sm:pt-3 md:pt-6 pb-8">
<div className="w-full">
<div className="w-full rounded-xl border border-border bg-background px-4 py-4 text-muted-foreground">
<div className="flex min-w-0 items-center gap-4">
<img className="size-16 shrink-0 select-none rounded-md" src={instanceLogo} alt="" draggable={false} />
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight text-foreground">{instanceTitle}</h1>
<div className="mx-auto w-full max-w-2xl px-1 py-6 sm:py-8">
<header>
<img className="size-10 shrink-0 select-none rounded-md" src={instanceLogo} alt="" draggable={false} />
<div className="mt-4 flex flex-wrap items-center gap-2">
<h1 className="text-lg font-semibold tracking-tight text-foreground">{instanceTitle}</h1>
{profile.demo && <Badge variant="warning">Demo</Badge>}
</div>
<p className="mt-1 text-sm text-muted-foreground">{instanceTagline}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
{profile.version && <Chip href={releaseUrl || undefined}>{versionLabel}</Chip>}
{shortCommit && <Chip href={commitUrl}>{shortCommit}</Chip>}
{isCustomBranded && <Chip>Powered by Memos</Chip>}
</div>
</div>
</div>
<p className="mt-1 max-w-md text-[26px] font-light leading-snug tracking-[-0.015em] text-foreground">{instanceTagline}</p>
</header>
<nav aria-label="Project links" className="mt-5">
<section className="mt-9">
<SectionLabel>{t("about.build")}</SectionLabel>
<dl className="mt-2.5 border-t border-border">
{buildRows.map((row) => (
<div key={row.label} className="grid grid-cols-[110px_1fr] items-center border-b border-border/60 py-2">
<dt className="text-[13px] text-muted-foreground">{row.label}</dt>
<dd className="m-0 flex min-w-0 items-center">{row.value}</dd>
</div>
))}
</dl>
</section>
<section className="mt-9">
<SectionLabel>{t("about.project")}</SectionLabel>
<nav aria-label="Project links" className="mt-2.5 border-t border-border">
{projectLinks.map((link) => (
<a
key={link.href}
className="group flex flex-col gap-0.5 border-t border-border py-3 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4"
className="group flex items-baseline justify-between gap-4 border-b border-border/60 py-2.5"
href={link.href}
target="_blank"
rel="noreferrer"
>
<span className="min-w-0">
<span className="text-sm font-medium text-foreground group-hover:underline group-hover:underline-offset-2">
<span className="flex min-w-0 items-baseline gap-2">
{link.icon && <link.icon className="size-3.5 shrink-0 translate-y-0.5 text-muted-foreground" />}
<span className="text-[13px] font-medium text-foreground group-hover:underline group-hover:underline-offset-2">
{link.label}
</span>
<span className="ml-2 text-xs text-muted-foreground">{link.note}</span>
<span className="hidden truncate text-xs text-muted-foreground sm:inline">{link.note}</span>
</span>
<span className="inline-flex shrink-0 items-center gap-1 font-mono text-xs text-muted-foreground group-hover:text-foreground">
{link.href.replace("https://", "")}
@@ -93,9 +121,9 @@ const About = () => {
</a>
))}
</nav>
</section>
<p className="border-t border-border pt-3 text-xs text-muted-foreground">Free and open source under the MIT license.</p>
</div>
<p className="mt-8 text-xs text-muted-foreground">Free and open source under the MIT license.</p>
</div>
</section>
);