Handle password change when the user never had one

This commit is contained in:
Zoe Roux 2026-03-25 20:09:46 +01:00
parent b5ace8d6ed
commit f17af7c75d
No known key found for this signature in database
13 changed files with 62 additions and 32 deletions

View File

@ -31,7 +31,7 @@ jobs:
- uses: actions/setup-go@v6
with:
go-version: '^1.22.5'
go-version: '^1.26.0'
cache-dependency-path: ./auth/go.sum
- name: Install dependencies

View File

@ -16,6 +16,8 @@ type User struct {
Username string `json:"username" example:"zoriya"`
// Email of the user. Can be used as a login.
Email string `json:"email" format:"email" example:"kyoo@zoriya.dev"`
// False if the user has never setup a password and only used oidc.
HasPassword bool `json:"hasPassword"`
// When was this account created?
CreatedDate time.Time `json:"createdDate" example:"2025-03-29T18:20:05.267Z"`
// When was the last time this account made any authorized request?
@ -52,7 +54,6 @@ type EditUserDto struct {
}
type EditPasswordDto struct {
OldPassword string `json:"oldPassword" validate:"required" example:"password1234"`
NewPassword string `json:"newPassword" validate:"required" example:"password1234"`
OldPassword *string `json:"oldPassword" example:"password1234"`
NewPassword string `json:"newPassword" validate:"required" example:"password1234"`
}

View File

@ -468,14 +468,14 @@ func (h *Handler) OidcUnlink(c *echo.Context) error {
ctx := c.Request().Context()
user, err := h.db.GetUser(ctx, dbc.GetUserParams{UseId: true, Id: uid})
if err != nil {
return err
}
if err == pgx.ErrNoRows {
return echo.NewHTTPError(http.StatusNotFound, "No user found")
} else if err != nil {
return nil
}
if user.User.Password == nil {
return echo.NewHTTPError(http.StatusUnprocessableEntity, "You must configure a password before unlinking your OIDC provider")
}
err = h.db.DeleteOidcHandle(ctx, dbc.DeleteOidcHandleParams{
UserPk: user.User.Pk,

View File

@ -23,6 +23,7 @@ func MapDbUser(user *dbc.User) User {
Id: user.Id,
Username: user.Username,
Email: user.Email,
HasPassword: user.Password != nil,
CreatedDate: user.CreatedDate,
LastSeen: user.LastSeen,
Claims: user.Claims,
@ -474,15 +475,20 @@ func (h *Handler) ChangePassword(c *echo.Context) error {
return err
}
match, err := argon2id.ComparePasswordAndHash(
req.OldPassword,
*user.User.Password,
)
if err != nil {
return err
}
if !match {
return echo.NewHTTPError(http.StatusForbidden, "Invalid password")
if user.User.Password != nil {
if req.OldPassword == nil {
return echo.NewHTTPError(http.StatusUnprocessableEntity, "Missing old password")
}
match, err := argon2id.ComparePasswordAndHash(
*req.OldPassword,
*user.User.Password,
)
if err != nil {
return err
}
if !match {
return echo.NewHTTPError(http.StatusForbidden, "Invalid password")
}
}
pass, err := argon2id.CreateHash(req.NewPassword, argon2id.DefaultParams)

View File

@ -5,9 +5,9 @@ export const User = z
id: z.string(),
username: z.string(),
email: z.string(),
hasPassword: z.boolean().default(true),
claims: z.object({
permissions: z.array(z.string()),
// hasPassword: z.boolean().default(true),
settings: z
.object({
downloadQuality: z
@ -37,7 +37,7 @@ export const User = z
z.string(),
z.object({
id: z.string(),
username: z.string().nullable().default(""),
username: z.string(),
profileUrl: z.string().nullable(),
}),
)

View File

@ -5,7 +5,7 @@ import type {
ReactNode,
Ref,
} from "react";
import { type Falsy, type PressableProps, View } from "react-native";
import type { Falsy, PressableProps, View } from "react-native";
import { cn } from "~/utils";
import { Icon } from "./icons";
import { PressableFeedback } from "./links";

View File

@ -12,12 +12,15 @@ export const Overlay = ({
close,
children,
scroll = true,
className,
...props
}: {
icon?: IconType;
title: string;
close?: () => void;
children: ReactNode;
scroll?: boolean;
className?: string;
}) => {
return (
<Pressable
@ -39,9 +42,13 @@ export const Overlay = ({
{close && <IconButton icon={Close} onPress={close} />}
</View>
{scroll ? (
<ScrollView className="p-6">{children}</ScrollView>
<ScrollView className={cn("p-6", className)} {...props}>
{children}
</ScrollView>
) : (
<View className="flex-1">{children}</View>
<View className={cn("flex-1", className)} {...props}>
{children}
</View>
)}
</Pressable>
</Pressable>
@ -54,15 +61,17 @@ export const Popup = ({
close,
children,
scroll,
...props
}: {
icon?: IconType;
title: string;
close?: () => void;
children: ReactNode;
scroll?: boolean;
className?: string;
}) => {
return (
<Overlay icon={icon} title={title} close={close} scroll={scroll}>
<Overlay icon={icon} title={title} close={close} scroll={scroll} {...props}>
{children}
</Overlay>
);

View File

@ -101,6 +101,7 @@ export const AddVideoFooter = ({
return (
<ComboBox
Trigger={(props) => (
// @ts-expect-error prop mismatch due to generic
<Button
icon={LibraryAdd}
text={t("videos-map.add")}

View File

@ -76,6 +76,7 @@ const AddMovieVideoFooter = ({ slug }: { slug: string }) => {
return (
<ComboBox
Trigger={(props) => (
// @ts-expect-error prop mismatch due to generic
<Button
icon={LibraryAdd}
text={t("videos-map.add")}

View File

@ -7,9 +7,9 @@ import { defaultApiUrl } from "~/providers/account-provider";
import { useQueryState } from "~/utils";
import { FormPage } from "./form";
import { login } from "./logic";
import { OidcLogin } from "./oidc";
import { PasswordInput } from "./password-input";
import { ServerUrlPage } from "./server-url";
import { OidcLogin } from "./oidc";
export const RegisterPage = () => {
const [apiUrl] = useQueryState("apiUrl", defaultApiUrl);

View File

@ -59,7 +59,7 @@ export const AccountSettings = () => {
compute: (body: { oldPassword: string; newPassword: string }) => ({
body,
}),
invalidate: null,
invalidate: ["auth", "users", "me"],
});
return (
@ -189,7 +189,7 @@ export const AccountSettings = () => {
<ChangePasswordPopup
icon={Password}
label={t("settings.account.password.label")}
hasPassword={true}
hasPassword={account.hasPassword}
apply={async (op, np) =>
await editPassword({ oldPassword: op, newPassword: np })
}
@ -273,6 +273,7 @@ const ChangePasswordPopup = ({
value={oldValue}
onChangeText={(v) => setOldValue(v)}
placeholder={t("settings.account.password.oldPassword")}
containerClassName="my-1"
/>
)}
<PasswordInput
@ -280,9 +281,10 @@ const ChangePasswordPopup = ({
value={newValue}
onChangeText={(v) => setNewValue(v)}
placeholder={t("settings.account.password.newPassword")}
containerClassName="my-1"
/>
{error && <P className="text-red-500">{error}</P>}
<View className="flex-row gap-2 self-end">
<View className="my-1 flex-row gap-2 self-end">
<Button
text={t("misc.cancel")}
onPress={() => close()}

View File

@ -45,10 +45,10 @@ export const SettingsContainer = ({
extraTop,
...props
}: {
children: ReactElement | (ReactElement | Falsy)[] | Falsy;
children: ReactNode;
title: string;
extra?: ReactElement;
extraTop?: ReactElement;
extra?: ReactNode;
extraTop?: ReactNode;
}) => {
return (
<Container {...props}>

View File

@ -1,16 +1,18 @@
import Badge from "@material-symbols/svg-400/outlined/badge.svg";
import Remove from "@material-symbols/svg-400/outlined/close.svg";
import OpenProfile from "@material-symbols/svg-400/outlined/open_in_new.svg";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Image } from "react-native";
import { type KyooError, User } from "~/models";
import { Button, IconButton, Link, P, Skeleton, tooltip } from "~/primitives";
import { type QueryIdentifier, useFetch, useMutation } from "~/query";
import { Preference, SettingsContainer } from "./base";
import { OidcLogin } from "../login/oidc";
import { User } from "~/models";
import { Preference, SettingsContainer } from "./base";
export const OidcSettings = () => {
const { t } = useTranslation();
const [unlinkError, setUnlinkError] = useState<string | null>(null);
const { data } = useFetch(OidcLogin.query());
const { data: user } = useFetch(OidcSettings.query());
const { mutateAsync: unlinkAccount } = useMutation({
@ -23,6 +25,7 @@ export const OidcSettings = () => {
return (
<SettingsContainer title={t("settings.oidc.label")}>
{unlinkError && <P className="text-red-500">{unlinkError}</P>}
{data && user
? Object.entries(data.oidc).map(([id, x]) => {
const acc = user.oidc[id];
@ -60,7 +63,14 @@ export const OidcSettings = () => {
)}
<IconButton
icon={Remove}
onPress={() => unlinkAccount(id)}
onPress={async () => {
setUnlinkError(null);
try {
await unlinkAccount(id);
} catch (e) {
setUnlinkError((e as KyooError).message);
}
}}
{...tooltip(
t("settings.oidc.delete", { provider: x.name }),
)}