8000 feat(auth): remember when someone has seen the welcome tour by adeelahmad · Pull Request #152 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion fastapi_backend/app/api/v1/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,14 @@ def me(user: CurrentUser, db: Db) -> Me:
names = store.space_names(db)
roles: dict[str, Any] = {names.get(k, str(k)): v for k, v in user.roles.items()}
partial = sorted(names.get(k, str(k)) for k in user.collections if k not in user.roles)
return Me(user=UserPublic(**(auth.active_account(db, user.id) or {})), roles=roles, partial=partial, via=user.via, scope=user.scope)
return Me(
user=UserPublic(**(auth.active_account(db, user.id) or {})),
roles=roles,
partial=partial,
via=user.via,
scope=user.scope,
toured_at=auth.toured_at(db, user.id),
)


@router.patch("/me")
Expand All @@ -127,6 +134,13 @@ def update_me(body: MeUpdate, user: Writer, db: Db) -> Me:
return me(user, db)


@router.post("/me/tour")
def finish_tour(user: Writer, db: Db) -> Me:
"""You finished or skipped the welcome tour, so it doesn't open again. Repeating it keeps the first time."""
auth.finish_tour(db, user.id)
return me(user, db)


@router.post("/password")
def change_password(body: PasswordChange, user: CurrentUser, db: Db, cfg: Cfg) -> Ok:
"""Change your own password, with your current one (signed in; not with an API token). Your other sessions end
Expand Down
15 changes: 15 additions & 0 deletions fastapi_backend/app/domain/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,21 @@ def rename_account(db, uid, name):
return n


def toured_at(db, uid):
"""When someone finished or skipped the welcome tour the web app shows after their first sign-in, else None."""
return (db.one("SELECT toured_at FROM $r", r=R("account", uid)) or {}).get("toured_at")


def finish_tour(db, uid):
"""Someone finished or skipped the welcome tour, so it doesn't open again (the first time counts)."""
db.q("UPDATE $r SET toured_at = $t WHERE toured_at = NONE", r=R("account", uid), t=store.now())


def migrate_tours(db):
"""People who had signed in before the welcome tour existed already know their way around: it doesn't open for them."""
db.q("UPDATE account SET toured_at = $t WHERE toured_at = NONE AND last_login_at != NONE", t=store.now())


# ---------- sessions (refresh tokens) and API tokens ----------
# The web app signs in through NextAuth: the API hands out a short-lived JWT access token (see app.core.security) and a
# long-lived refresh token. Only the refresh token's hash is stored, one row per signed-in device; every refresh
Expand Down
4 changes: 3 additions & 1 deletion fastapi_backend/app/domain/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,15 @@

def steps():
"""[(name, fn(db))] in the order they run. Append only."""
from . import access, hierarchy
from . import access, auth, hierarchy

return [
# IIIF-only access levels (public, transcript, signed-in, private) become the access setting
("access-levels", access.migrate_legacy),
# every namespace gets its default collection and every recording a home
("collection-homes", hierarchy.migrate_homes),
# people who signed in before the first-sign-in welcome tour existed don't get it
("tour-seen", auth.migrate_tours),
]


Expand Down
3 changes: 3 additions & 0 deletions fastapi_backend/app/schemas/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ class Me(ResponseModel):
)
via: Literal["access", "token", "oauth"]
scope: Literal["read", "write"]
toured_at: str | None = Field(
None, description="when you finished or skipped the welcome tour; until then the web app opens it once you sign in"
)


class ForgotPasswordRequest(RequestModel):
Expand Down
15 changes: 15 additions & 0 deletions fastapi_backend/tests/api/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,3 +95,18 @@ def test_password_reset(client, db, caplog):
assert client.post("/api/v1/auth/password/reset", json={"token": raw, "password": "a new password 1"}).status_code == 200
assert client.post("/api/v1/auth/password/reset", json={"token": raw, "password": "a new password 2"}).status_code == 400 # once
login(client, "ada@x.io", "a new password 1")


def test_welcome_tour_opens_until_finished(client, db):
make_user(db, "ada@x.io", "admin password 1", admin=True)
h = login(client, "ada@x.io", "admin password 1")
assert client.get("/api/v1/auth/me", headers=h).json()["toured_at"] is None
done = client.post("/api/v1/auth/me/tour", headers=h)
assert done.status_code == 200, done.text
first = done.json()["toured_at"]
assert first and client.get("/api/v1/auth/me", headers=h).json()["toured_at"] == first
db.q("UPDATE account SET toured_at = '2000-01-01T00:00:00+00:00'")
assert client.post("/api/v1/auth/me/tour", headers=h).json()["toured_at"] == "2000-01-01T00:00:00+00:00" # first time kept

tok = client.post("/api/v1/tokens", json={"name": "ci", "scope": "read"}, headers=h).json()
assert client.post("/api/v1/auth/me/tour", headers={"Authorization": f"Bearer {tok['token']}"}).status_code == 403
2 changes: 1 addition & 1 deletion fastapi_backend/tests/api/test_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def make():
def test_fresh_database_without_namespaces_starts(folder):
db = store.connect(bare_cfg(folder))
try:
assert migrations.pending(db) == [] and [m["state"] for m in migrations.status(db)] == ["done", "done"]
assert migrations.pending(db) == [] and [m["state"] for m in migrations.status(db)] == ["done", "done", "done"]
assert store.space_names(db) == {}
finally:
db.close()
Expand Down
18 changes: 14 additions & 4 deletions fastapi_backend/tests/domain/test_migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

# Every step that has shipped, in order. A database remembers steps by name, so these may never be renamed, reordered
# or removed: add new steps to the end of migrations.steps() and here.
SHIPPED = ["access-levels", "collection-homes"]
SHIPPED = ["access-levels", "collection-homes", "tour-seen"]


def test_steps_are_append_only():
Expand Down Expand Up @@ -62,8 +62,8 @@ def test_steps_an_older_lens_counted_are_not_run_again(db):
db.q("DELETE migration")
db.q("UPSERT $r SET n = 1", r=R("seq", "migrations"))
rid = _old_shape(db)
assert migrations.pending(db) == ["collection-homes"]
assert store.migrate(db) == ["collection-homes"]
assert migrations.pending(db) == ["collection-homes", "tour-seen"]
assert store.migrate(db) == ["collection-homes", "tour-seen"]
row = db.one("SELECT meta_json, collection FROM $r", r=R("recording", rid))
assert json.loads(row["meta_json"]) == {"access": "signed-in"} # access-levels was counted as done, so not rerun
assert row["collection"] == store.default_collection(db, store.ns_id(db, "pods"))
Expand Down Expand Up @@ -139,7 +139,7 @@ def test_lens_migrations_lists_and_runs(tmp_path, monkeypatch, capsys):
assert "pending access-levels" in out and "pending collection-homes" in out
cli.main(["--config", str(cfg_path), "migrations", "--run"])
out = capsys.readouterr().out
assert "ran 2 upgrade(s): access-levels, collection-homes" in out
assert "ran 3 upgrade(s): access-levels, collection-homes, tour-seen" in out
assert "done access-levels" in out


Expand Down Expand Up @@ -230,3 +230,13 @@ def test_lens_backup(tmp_path, monkeypatch, capsys):
cli.main(["--config", str(cfg_path), "backup"])
assert "backed up to " in capsys.readouterr().out
assert len(list((tmp_path / "data" / "backups").glob("manual-*.surrealkv"))) == 1


def test_people_who_signed_in_before_the_tour_skip_it(db):
from app.domain import auth

old = auth.create_account(db, "old@x.io", "old password 12", "Old")
new = auth.create_account(db, "new@x.io", "new password 12", "New")
db.q("UPDATE $r SET last_login_at = $t", r=R("account", old), t=store.now())
dict(migrations.steps())["tour-seen"](db)
assert auth.toured_at(db, old) and auth.toured_at(db, new) is None
3 changes: 3 additions & 0 deletions nextjs-frontend/app/openapi-client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -772,6 +772,9 @@ export type {
FinishErrors,
FinishResponse,
FinishResponses,
FinishTourData,
FinishTourResponse,
FinishTourResponses,
FixScreenTextData,
FixScreenTextError,
FixScreenTextErrors,
Expand Down
16 changes: 16 additions & 0 deletions nextjs-frontend/app/openapi-client/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,8 @@ import type {
FinishData,
FinishErrors,
FinishResponses,
FinishTourData,
FinishTourResponses,
FixScreenTextData,
FixScreenTextErrors,
FixScreenTextResponses,
Expand Down Expand Up @@ -1447,6 +1449,20 @@ export class Auth {
});
}

/**
* Finish Tour
*
* You finished or skipped the welcome tour, so it doesn't open again. Repeating it keeps the first time.
*/
public static finishTour<ThrowOnError extends boolean = false>(
options?: Options<FinishTourData, ThrowOnError>,
): RequestResult<FinishTourResponses, unknown, ThrowOnError> {
return (options?.client ?? client).post<FinishTourResponses, unknown, ThrowOnError>({
url: "/api/v1/auth/me/tour",
...options,
});
}

/**
* Change Password
*
Expand Down
22 changes: 22 additions & 0 deletions nextjs-frontend/app/openapi-client/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6550,6 +6550,12 @@ export type Me = {
* Scope
*/
scope: "read" | "write";
/**
* Toured At
*
* when you finished or skipped the welcome tour; until then the web app opens it once you sign in
*/
toured_at?: string | null;
[key: string]: unknown;
};

Expand Down Expand Up @@ -16104,6 +16110,22 @@ export type UpdateMeResponses = {

export type UpdateMeResponse = UpdateMeResponses[keyof UpdateMeResponses];

export type FinishTourData = {
body?: never;
path?: never;
query?: never;
url: "/api/v1/auth/me/tour";
};

export type FinishTourResponses = {
/**
* Successful Response
*/
200: Me;
};

export type FinishTourResponse = FinishTourResponses[keyof FinishTourResponses];

export type ChangePasswordData = {
body: PasswordChange;
path?: never;
Expand Down
34 changes: 34 additions & 0 deletions nextjs-frontend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,28 @@
}
}
},
"/api/v1/auth/me/tour": {
"post": {
"tags": [
"auth"
],
"summary": "Finish Tour",
"description": "You finished or skipped the welcome tour, so it doesn't open again. Repeating it keeps the first time.",
"operationId": "finish_tour",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Me"
}
}
}
}
}
}
},
"/api/v1/auth/password": {
"post": {
"tags": [
Expand Down Expand Up @@ -33846,6 +33868,18 @@
"write"
],
"title": "Scope"
},
"toured_at": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Toured At",
"description": "when you finished or skipped the welcome tour; until then the web app opens it once you sign in"
}
},
"additionalProperties": true,
Expand Down
Loading
0