chore(web): update about page and access token settings
This commit is contained in:
Executable
+359
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
+53
-25
@@ -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>
|
||||
{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 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 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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user