From cd451a685d58e058a4d677e791d65c04a06130b7 Mon Sep 17 00:00:00 2001 From: Paul Bienkowski Date: Tue, 13 Sep 2022 09:45:29 +0200 Subject: [PATCH] Add display_name field to users to specify a new name within the application, without changing the login name --- .../99a3d2eb08f9_add_user_display_name.py | 26 +++ api/obs/api/db.py | 9 +- api/obs/api/routes/tiles.py | 9 +- api/obs/api/routes/tracks.py | 6 +- api/obs/api/routes/users.py | 5 + frontend/src/components/Avatar/index.tsx | 41 +++-- frontend/src/pages/MapPage/index.tsx | 2 +- frontend/src/pages/SettingsPage.tsx | 45 +++-- frontend/src/pages/TrackEditor.tsx | 2 +- .../src/pages/TrackPage/TrackComments.tsx | 8 +- frontend/src/pages/TrackPage/index.tsx | 4 +- frontend/src/pages/TracksPage.tsx | 168 +++++++++++------- frontend/src/translations/de.yaml | 8 +- frontend/src/translations/en.yaml | 8 +- frontend/src/types.ts | 3 +- 15 files changed, 223 insertions(+), 121 deletions(-) create mode 100644 api/migrations/versions/99a3d2eb08f9_add_user_display_name.py diff --git a/api/migrations/versions/99a3d2eb08f9_add_user_display_name.py b/api/migrations/versions/99a3d2eb08f9_add_user_display_name.py new file mode 100644 index 0000000..9d6a637 --- /dev/null +++ b/api/migrations/versions/99a3d2eb08f9_add_user_display_name.py @@ -0,0 +1,26 @@ +"""add user display_name + +Revision ID: 99a3d2eb08f9 +Revises: a9627f63fbed +Create Date: 2022-09-13 07:30:18.747880 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "99a3d2eb08f9" +down_revision = "a9627f63fbed" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "user", sa.Column("display_name", sa.String, nullable=True), schema="public" + ) + + +def downgrade(): + op.drop_column("user", "display_name", schema="public") diff --git a/api/obs/api/db.py b/api/obs/api/db.py index 10dbbb3..64d8e50 100644 --- a/api/obs/api/db.py +++ b/api/obs/api/db.py @@ -354,6 +354,7 @@ class User(Base): updated_at = Column(DateTime, nullable=False, server_default=NOW, onupdate=NOW) sub = Column(String, unique=True, nullable=False) username = Column(String, unique=True, nullable=False) + display_name = Column(String, nullable=True) email = Column(String, nullable=False) bio = Column(TEXT) image = Column(String) @@ -374,11 +375,15 @@ class User(Base): self.api_key = secrets.token_urlsafe(24) def to_dict(self, for_user_id=None): - return { - "username": self.username, + result = { + "id": self.id, + "displayName": self.display_name or self.username, "bio": self.bio, "image": self.image, } + if for_user_id == self.id: + result["username"] = self.username + return result async def rename(self, config, new_name): old_name = self.username diff --git a/api/obs/api/routes/tiles.py b/api/obs/api/routes/tiles.py index 1f5771a..9b6b652 100644 --- a/api/obs/api/routes/tiles.py +++ b/api/obs/api/routes/tiles.py @@ -66,12 +66,9 @@ def get_filter_options( * start (datetime|None) * end (datetime|None) """ - user_id = None - username = req.ctx.get_single_arg("user", default=None) - if username is not None: - if req.ctx.user is None or req.ctx.user.username != username: - raise Forbidden() - user_id = req.ctx.user.id + user_id = req.ctx.get_single_arg("user", default=None, convert=int) + if user_id is not None and (req.ctx.user is None or req.ctx.user.id != user_id): + raise Forbidden() parse_date = lambda s: dateutil.parser.parse(s) start = req.ctx.get_single_arg("start", default=None, convert=parse_date) diff --git a/api/obs/api/routes/tracks.py b/api/obs/api/routes/tracks.py index d0c4425..8ae1c6a 100644 --- a/api/obs/api/routes/tracks.py +++ b/api/obs/api/routes/tracks.py @@ -63,13 +63,13 @@ async def _return_tracks(req, extend_query, limit, offset): async def get_tracks(req): limit = req.ctx.get_single_arg("limit", default=20, convert=int) offset = req.ctx.get_single_arg("offset", default=0, convert=int) - author = req.ctx.get_single_arg("author", default=None, convert=str) + # author = req.ctx.get_single_arg("author", default=None, convert=int) def extend_query(q): q = q.where(Track.public) - if author is not None: - q = q.where(User.username == author) + # if author is not None: + # q = q.where(Track.author_id == author) return q diff --git a/api/obs/api/routes/users.py b/api/obs/api/routes/users.py index 2c86e3d..60b0c19 100644 --- a/api/obs/api/routes/users.py +++ b/api/obs/api/routes/users.py @@ -12,7 +12,9 @@ from obs.api import __version__ as version def user_to_json(user): return { + "id": user.id, "username": user.username, + "displayName": user.display_name, "email": user.email, "bio": user.bio, "image": user.image, @@ -36,6 +38,9 @@ async def put_user(req): if key in data and isinstance(data[key], (str, type(None))): setattr(user, key, data[key]) + if "displayName" in data: + user.display_name = data["displayName"] or None + if "areTracksVisibleForAll" in data: user.are_tracks_visible_for_all = bool(data["areTracksVisibleForAll"]) diff --git a/frontend/src/components/Avatar/index.tsx b/frontend/src/components/Avatar/index.tsx index a2d49f5..72f85eb 100644 --- a/frontend/src/components/Avatar/index.tsx +++ b/frontend/src/components/Avatar/index.tsx @@ -1,39 +1,42 @@ -import React from 'react' -import {Comment} from 'semantic-ui-react' -import classnames from 'classnames' +import React from "react"; +import { Comment } from "semantic-ui-react"; +import classnames from "classnames"; -import './styles.less' +import "./styles.less"; function hashCode(s) { - let hash = 0 + let hash = 0; for (let i = 0; i < s.length; i++) { - hash = (hash << 5) - hash + s.charCodeAt(i) - hash |= 0 + hash = (hash << 5) - hash + s.charCodeAt(i); + hash |= 0; } - return hash + return hash; } function getColor(s) { - const h = Math.floor(hashCode(s)) % 360 - return `hsl(${h}, 50%, 50%)` + const h = Math.floor(hashCode(s)) % 360; + return `hsl(${h}, 50%, 50%)`; } -export default function Avatar({user, className}) { - const {image, username} = user || {} +export default function Avatar({ user, className }) { + const { image, displayName } = user || {}; if (image) { - return + return ; } - if (!username) { - return
+ if (!displayName) { + return
; } - const color = getColor(username) + const color = getColor(displayName); return ( -
- {username && {username[0]}} +
+ {displayName && {displayName[0]}}
- ) + ); } diff --git a/frontend/src/pages/MapPage/index.tsx b/frontend/src/pages/MapPage/index.tsx index 41691ec..4a0a577 100644 --- a/frontend/src/pages/MapPage/index.tsx +++ b/frontend/src/pages/MapPage/index.tsx @@ -169,7 +169,7 @@ function MapPage({ login }) { const query = new URLSearchParams(); if (login) { if (mapConfig.filters.currentUser) { - query.append("user", login.username); + query.append("user", login.id); } if (mapConfig.filters.dateMode === "range") { diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index c0b9bb5..11be6e5 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -68,18 +68,37 @@ const SettingsPage = connect((state) => ({ login: state.login }), { setLogin })(
{t("SettingsPage.profile.title")}
- {t("SettingsPage.profile.publicNotice")} -
- - - + + + + + + {t("SettingsPage.profile.username.hint")} + + + + {t("SettingsPage.profile.publicNotice")} + + + + + + + + + {t("SettingsPage.profile.displayName.fallbackNotice")} + + + @@ -103,7 +122,7 @@ const SettingsPage = connect((state) => ({ login: state.login }), { setLogin })( - + @@ -140,7 +159,7 @@ function CopyInput({ value, ...props }) { } position="top right" open={success != null} - content={success ? t('general.copied') : t('general.copyError')} + content={success ? t("general.copied") : t("general.copyError")} /> ); } diff --git a/frontend/src/pages/TrackEditor.tsx b/frontend/src/pages/TrackEditor.tsx index 393ec39..0880f1c 100644 --- a/frontend/src/pages/TrackEditor.tsx +++ b/frontend/src/pages/TrackEditor.tsx @@ -76,7 +76,7 @@ const TrackEditor = connect((state) => ({ login: state.login }))( ); const loading = busy || track == null; - const isAuthor = login?.username === track?.author?.username; + const isAuthor = login?.id === track?.author?.id; // Navigate to track detials if we are not the author React.useEffect(() => { diff --git a/frontend/src/pages/TrackPage/TrackComments.tsx b/frontend/src/pages/TrackPage/TrackComments.tsx index 4f221f5..5c285b6 100644 --- a/frontend/src/pages/TrackPage/TrackComments.tsx +++ b/frontend/src/pages/TrackPage/TrackComments.tsx @@ -60,7 +60,9 @@ export default function TrackComments({ - {comment.author.username} + + {comment.author.displayName} +
@@ -69,7 +71,7 @@ export default function TrackComments({ {comment.body} - {login?.username === comment.author.username && ( + {login?.id === comment.author.id && ( { @@ -77,7 +79,7 @@ export default function TrackComments({ e.preventDefault(); }} > - {t('general.delete')} + {t("general.delete")} )} diff --git a/frontend/src/pages/TrackPage/index.tsx b/frontend/src/pages/TrackPage/index.tsx index b317c03..d7bc8d4 100644 --- a/frontend/src/pages/TrackPage/index.tsx +++ b/frontend/src/pages/TrackPage/index.tsx @@ -246,7 +246,7 @@ const TrackPage = connect((state) => ({ login: state.login }))( [slug] ); - const isAuthor = login?.username === data?.track?.author?.username; + const isAuthor = login?.id === data?.track?.author?.id; const { track, trackData, comments } = data || {}; @@ -355,7 +355,7 @@ const TrackPage = connect((state) => ({ login: state.login }))( onConfirm={hideDownloadError} header={t("TrackPage.downloadFailed")} content={String(downloadError)} - confirmButton={t('general.ok')} + confirmButton={t("general.ok")} /> ); diff --git a/frontend/src/pages/TracksPage.tsx b/frontend/src/pages/TracksPage.tsx index 033d6f2..68e3a80 100644 --- a/frontend/src/pages/TracksPage.tsx +++ b/frontend/src/pages/TracksPage.tsx @@ -1,49 +1,65 @@ -import React, {useCallback} from 'react' -import {connect} from 'react-redux' -import {Button, Message, Item, Header, Loader, Pagination, Icon} from 'semantic-ui-react' -import {useObservable} from 'rxjs-hooks' -import {Link} from 'react-router-dom' -import {of, from, concat} from 'rxjs' -import {map, switchMap, distinctUntilChanged} from 'rxjs/operators' -import _ from 'lodash' -import {useTranslation, Trans as Translate} from 'react-i18next' +import React, { useCallback } from "react"; +import { connect } from "react-redux"; +import { + Button, + Message, + Item, + Header, + Loader, + Pagination, + Icon, +} from "semantic-ui-react"; +import { useObservable } from "rxjs-hooks"; +import { Link } from "react-router-dom"; +import { of, from, concat } from "rxjs"; +import { map, switchMap, distinctUntilChanged } from "rxjs/operators"; +import _ from "lodash"; +import { useTranslation, Trans as Translate } from "react-i18next"; -import type {Track} from 'types' -import {Avatar, Page, StripMarkdown, FormattedDate, Visibility} from 'components' -import api from 'api' -import {useQueryParam} from 'query' +import type { Track } from "types"; +import { + Avatar, + Page, + StripMarkdown, + FormattedDate, + Visibility, +} from "components"; +import api from "api"; +import { useQueryParam } from "query"; -function TrackList({privateTracks}: {privateTracks: boolean}) { - const [page, setPage] = useQueryParam('page', 1, Number) +function TrackList({ privateTracks }: { privateTracks: boolean }) { + const [page, setPage] = useQueryParam("page", 1, Number); - const pageSize = 10 + const pageSize = 10; const data: { - tracks: Track[] - trackCount: number + tracks: Track[]; + trackCount: number; } | null = useObservable( (_$, inputs$) => inputs$.pipe( map(([page, privateTracks]) => { - const url = '/tracks' + (privateTracks ? '/feed' : '') - const query = {limit: pageSize, offset: pageSize * (page - 1)} - return {url, query} + const url = "/tracks" + (privateTracks ? "/feed" : ""); + const query = { limit: pageSize, offset: pageSize * (page - 1) }; + return { url, query }; }), distinctUntilChanged(_.isEqual), - switchMap((request) => concat(of(null), from(api.get(request.url, {query: request.query})))) + switchMap((request) => + concat(of(null), from(api.get(request.url, { query: request.query }))) + ) ), null, [page, privateTracks] - ) + ); - const {tracks, trackCount} = data || {tracks: [], trackCount: 0} - const loading = !data - const totalPages = Math.ceil(trackCount / pageSize) - const {t} = useTranslation() + const { tracks, trackCount } = data || { tracks: [], trackCount: 0 }; + const loading = !data; + const totalPages = Math.ceil(trackCount / pageSize); + const { t } = useTranslation(); return (
- + {!loading && totalPages > 1 && ( {tracks.map((track: Track) => ( - + ))} ) : ( )}
- ) + ); } export function NoPublicTracksMessage() { @@ -72,27 +88,27 @@ export function NoPublicTracksMessage() { No public tracks yet. Upload the first! - ) + ); } -function maxLength(t: string|null, max: number): string|null { +function maxLength(t: string | null, max: number): string | null { if (t && t.length > max) { - return t.substring(0, max) + ' ...' + return t.substring(0, max) + " ..."; } else { - return t + return t; } } const COLOR_BY_STATUS = { - error: 'red', - complete: 'green', - created: 'gray', - queued: 'orange', - processing: 'orange', -} + error: "red", + complete: "green", + created: "gray", + queued: "orange", + processing: "orange", +}; -export function TrackListItem({track, privateTracks = false}) { - const {t} = useTranslation() +export function TrackListItem({ track, privateTracks = false }) { + const { t } = useTranslation(); return ( @@ -101,10 +117,14 @@ export function TrackListItem({track, privateTracks = false}) { - {track.title || t('general.unnamedTrack')} + {track.title || t("general.unnamedTrack")} - {privateTracks ? null : {t('TracksPage.createdBy', {author: track.author.username})}} + {privateTracks ? null : ( + + {t("TracksPage.createdBy", { author: track.author.displayName })} + + )} @@ -116,45 +136,57 @@ export function TrackListItem({track, privateTracks = false}) { - - - {' '} + + {" "} {t(`TracksPage.processing.${track.processingStatus}`)} )} - ) + ); } -function UploadButton({navigate, ...props}) { - const {t} = useTranslation() +function UploadButton({ navigate, ...props }) { + const { t } = useTranslation(); const onClick = useCallback( (e) => { - e.preventDefault() - navigate() + e.preventDefault(); + navigate(); }, [navigate] - ) + ); return ( - - ) + ); } -const TracksPage = connect((state) => ({login: (state as any).login}))(function TracksPage({login, privateTracks}) { - const {t} = useTranslation() - const title = privateTracks ? t('TracksPage.titleUser') : t('TracksPage.titlePublic') +const TracksPage = connect((state) => ({ login: (state as any).login }))( + function TracksPage({ login, privateTracks }) { + const { t } = useTranslation(); + const title = privateTracks + ? t("TracksPage.titleUser") + : t("TracksPage.titlePublic"); - return ( - -
{title}
- {privateTracks && } - -
- ) -}) + return ( + +
{title}
+ {privateTracks && } + +
+ ); + } +); -export default TracksPage +export default TracksPage; diff --git a/frontend/src/translations/de.yaml b/frontend/src/translations/de.yaml index b73d4e6..f5125db 100644 --- a/frontend/src/translations/de.yaml +++ b/frontend/src/translations/de.yaml @@ -206,9 +206,15 @@ SettingsPage: profile: title: Mein Profil - publicNotice: All diese Informationen sind öffentlich. + publicNotice: Alle Informationen ab hier sind öffentlich. username: label: Kontoname + hint: > + Der Kontoname kann beim Login-Dienst geändert werden, und wird beim + nächsten Login übernommen. + displayName: + label: Anzeigename + fallbackNotice: Sofern kein Anzeigename gewählt ist wird der Kontoname öffentlich angezeigt. bio: label: Bio avatarUrl: diff --git a/frontend/src/translations/en.yaml b/frontend/src/translations/en.yaml index 18c1d4b..cad0503 100644 --- a/frontend/src/translations/en.yaml +++ b/frontend/src/translations/en.yaml @@ -210,9 +210,15 @@ SettingsPage: profile: title: My profile - publicNotice: All of this information is public. + publicNotice: All of the information below is public. username: label: Username + hint: > + The username can be changed at the login provider and will be applied + when logging in the next time. + displayName: + label: Display name + fallbackNotice: As long as no display name is chosen, the username is shown publicly. bio: label: Bio avatarUrl: diff --git a/frontend/src/types.ts b/frontend/src/types.ts index cb7dd58..e3f5c74 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,7 +1,8 @@ import type {FeatureCollection, Feature, LineString, Point} from 'geojson' export type UserProfile = { - username: string + id: number | string + displayName: string image?: string | null bio?: string | null }