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
2 changes: 1 addition & 1 deletion .docker/kratos/email-password/identity.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"title": "Player name",
"minLength": 3,
"maxLength": 15,
"pattern": "^[a-zA-Z0-9_-]+$"
"pattern": "^[a-zA-Z0-9À-ÖØ-öø-ÿŒœ_-]+$"
}
},
"required": [
Expand Down
3 changes: 1 addition & 2 deletions client/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as generatedApi from './generated';
import {
AnonLinkData,
AnonSignupData, ChooseTrumpRequest,
CreateCustomGameRequest,
JoinGameRequest, MessageRequest, PingSeatRequest,
Expand Down Expand Up @@ -38,7 +37,7 @@ export const api = {
subscribe: (data: SubscribeData['body']) => generatedApi.subscribe({client: customClient, body: data}),
anonSignup: (data: AnonSignupData['body']) => generatedApi.anonSignup({client: customClient, body: data}),
anonLogout: () => generatedApi.anonLogout({client: customClient}),
anonLink: (data: AnonLinkData['body']) => generatedApi.anonLink({client: customClient, body: data}),
anonLink: () => generatedApi.anonLink({client: customClient}),
connect: () => generatedApi.connect({client: customClient}),

// Game
Expand Down
2 changes: 1 addition & 1 deletion client/src/api/generated/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// This file is auto-generated by @hey-api/openapi-ts

export { anonLink, anonLogout, anonSignup, connect, getAdminAnalyzeGameByCode, type Options, postAdminMessage, postGameCreate, postGameJoin, postGamePing, postGamePlay, postGameSchiebe, postGameTrump, postGameWeisen, subscribe, whoami } from './sdk.gen';
export type { AnalyzeGameStateResponse, AnalyzeHand, AnonLinkData, AnonLinkErrors, AnonLinkRequest, AnonLinkResponse, AnonLinkResponses, AnonLogoutData, AnonLogoutResponse, AnonLogoutResponses, AnonSignupData, AnonSignupErrors, AnonSignupRequest, AnonSignupResponse, AnonSignupResponse2, AnonSignupResponses, Card, CardInHand, CardOnTable, ChooseTrumpRequest, ClientOptions, ConnectData, ConnectErrors, ConnectResponse, ConnectResponses, CreateCustomGameRequest, CreateCustomGameResponse, GameFinished, GetAdminAnalyzeGameByCodeData, GetAdminAnalyzeGameByCodeResponse, GetAdminAnalyzeGameByCodeResponses, JoinGameRequest, JoinGameResponse, MessageRequest, PingSeatRequest, PlayCardRequest, PlayedCard, PlayedCardWithPlayer, Player, PlayerAtTable, PlayerWithCards, PlayerWithWeise, Position, PostAdminMessageData, PostAdminMessageResponse, PostAdminMessageResponses, PostGameCreateData, PostGameCreateResponse, PostGameCreateResponses, PostGameJoinData, PostGameJoinResponse, PostGameJoinResponses, PostGamePingData, PostGamePingResponse, PostGamePingResponses, PostGamePlayData, PostGamePlayResponse, PostGamePlayResponses, PostGameSchiebeData, PostGameSchiebeResponse, PostGameSchiebeResponses, PostGameTrumpData, PostGameTrumpResponse, PostGameTrumpResponses, PostGameWeisenData, PostGameWeisenResponse, PostGameWeisenResponses, Rank, SchiebeRequest, SeatState, State, SubscribeData, SubscribeErrors, SubscribeRequest, SubscribeResponse, SubscribeResponses, SuccessfulActionResponse, Suit, TeamWithPoints, TotalPoints, TrickWithCards, Trump, Weis, WeisenRequest, WeisType, WeisWithPoints, WhoamiData, WhoamiErrors, WhoamiResponse, WhoAmIResponse, WhoamiResponses } from './types.gen';
export type { AnalyzeGameStateResponse, AnalyzeHand, AnonLinkData, AnonLinkErrors, AnonLinkResponse, AnonLinkResponses, AnonLogoutData, AnonLogoutResponse, AnonLogoutResponses, AnonSignupData, AnonSignupErrors, AnonSignupRequest, AnonSignupResponse, AnonSignupResponse2, AnonSignupResponses, Card, CardInHand, CardOnTable, ChooseTrumpRequest, ClientOptions, ConnectData, ConnectErrors, ConnectResponse, ConnectResponses, CreateCustomGameRequest, CreateCustomGameResponse, GameFinished, GetAdminAnalyzeGameByCodeData, GetAdminAnalyzeGameByCodeResponse, GetAdminAnalyzeGameByCodeResponses, JoinGameRequest, JoinGameResponse, MessageRequest, PingSeatRequest, PlayCardRequest, PlayedCard, PlayedCardWithPlayer, Player, PlayerAtTable, PlayerWithCards, PlayerWithWeise, Position, PostAdminMessageData, PostAdminMessageResponse, PostAdminMessageResponses, PostGameCreateData, PostGameCreateResponse, PostGameCreateResponses, PostGameJoinData, PostGameJoinResponse, PostGameJoinResponses, PostGamePingData, PostGamePingResponse, PostGamePingResponses, PostGamePlayData, PostGamePlayResponse, PostGamePlayResponses, PostGameSchiebeData, PostGameSchiebeResponse, PostGameSchiebeResponses, PostGameTrumpData, PostGameTrumpResponse, PostGameTrumpResponses, PostGameWeisenData, PostGameWeisenResponse, PostGameWeisenResponses, Rank, SchiebeRequest, SeatState, State, SubscribeData, SubscribeErrors, SubscribeRequest, SubscribeResponse, SubscribeResponses, SuccessfulActionResponse, Suit, TeamWithPoints, TotalPoints, TrickWithCards, Trump, Weis, WeisenRequest, WeisType, WeisWithPoints, WhoamiData, WhoamiErrors, WhoamiResponse, WhoAmIResponse, WhoamiResponses } from './types.gen';
11 changes: 5 additions & 6 deletions client/src/api/generated/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,14 +201,13 @@ export const anonSignup = <ThrowOnError extends boolean = false>(options: Option

/**
* Link anonymous account to Ory account
*
* Links the anon player of the anon_token cookie to the Ory identity of the ory_kratos_session cookie. Both cookies have to be present, the anon_token cookie gets cleared on success.
*
*/
export const anonLink = <ThrowOnError extends boolean = false>(options: Options<AnonLinkData, ThrowOnError>) => (options.client ?? client).post<AnonLinkResponses, AnonLinkErrors, ThrowOnError>({
export const anonLink = <ThrowOnError extends boolean = false>(options?: Options<AnonLinkData, ThrowOnError>) => (options?.client ?? client).post<AnonLinkResponses, AnonLinkErrors, ThrowOnError>({
responseType: 'json',
security: [{ name: 'X-Anon-Token', type: 'apiKey' }],
url: '/auth/anon/link',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
...options
});
9 changes: 1 addition & 8 deletions client/src/api/generated/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,13 +239,6 @@ export type AnonSignupResponse = {
name: string;
};

export type AnonLinkRequest = {
/**
* Ory session token to link with anonymous account
*/
orySession: string;
};

export type MessageRequest = {
/**
* The feedback or message content
Expand Down Expand Up @@ -528,7 +521,7 @@ export type AnonSignupResponses = {
export type AnonSignupResponse2 = AnonSignupResponses[keyof AnonSignupResponses];

export type AnonLinkData = {
body: AnonLinkRequest;
body?: never;
path?: never;
query?: never;
url: '/auth/anon/link';
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/landing/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ export function Header() {
<Button
variant="outline"
size="lg"
onClick={() => navigate("/signup", { state: { isGuest: true } })}
onClick={() => navigate("/signup", { state: { isAnonSignup: true } })}
className="font-normal px-8"
>
{t("landing.header.playAsGuest")}
{t("landing.header.playAsAnon")}
</Button>
)}
</div>
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/login-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,15 @@ export function LoginForm() {
type="button"
variant="outline"
className="w-full"
onClick={() => navigate('/signup', {state: {isGuest: true, from: location.state?.from}})}
onClick={() => navigate('/signup', {state: {isAnonSignup: true, from: location.state?.from}})}
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"
strokeLinejoin="round" className="lucide lucide-user">
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
{t("auth.login.guestPlay")}
{t("auth.login.anonPlay")}
</Button>
</div>
<div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0
Expand Down
16 changes: 15 additions & 1 deletion client/src/components/settings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Gauge, Languages, LogOut, SettingsIcon, Spade, User} from 'lucide-react';
import {Gauge, Languages, LogOut, SettingsIcon, Spade, User, UserPlus} from 'lucide-react';
import {useSettingsStore} from "@/store/settings.ts";
import {PlaySpeed} from "@/types/play-speed.ts";
import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group.tsx";
Expand Down Expand Up @@ -35,6 +35,7 @@ interface SettingsProps {
const Settings = ({ triggerVariant = 'fixed', open: controlledOpen, onOpenChange }: SettingsProps) => {
const {language, setLanguage, cardDeck, setCardDeck, playSpeed, setPlaySpeed} = useSettingsStore();
const isAuthenticated = usePlayerStore(state => state.isAuthenticated)
const isAnon = usePlayerStore(state => state.isAnon)
const name = usePlayerStore(state => state.name)
const gameUuid = useGameStateStore(state => state.gameUuid)
const code = useGameStateStore(state => state.code)
Expand Down Expand Up @@ -125,6 +126,19 @@ const Settings = ({ triggerVariant = 'fixed', open: controlledOpen, onOpenChange
</div>
<Logout/>
</div>
{isAnon && (
<Button
variant="outline"
className="w-full"
onClick={() => {
setOpen(false)
navigate('/signup')
}}
>
<UserPlus className="h-4 w-4"/>
{t("auth.upgrade.title")}
</Button>
)}
<Separator/>
</>
)}
Expand Down
94 changes: 61 additions & 33 deletions client/src/components/signup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,39 @@ import {Alert, AlertDescription, AlertTitle} from "@/components/ui/alert.tsx";
import {useAnon} from "@/hooks/use-anon.tsx";
import {useAsyncAction} from "@/hooks/use-async-action";
import {useAuth} from "@/hooks/use-auth.tsx";
import {usePlayerStore} from "@/store/player.ts";

export function SignupForm() {
const location = useLocation()
const navigate = useNavigate()
const [isGuest, setIsGuest] = useState(location.state?.isGuest || false)
const [isAnonSignup, setIsAnonSignup] = useState(location.state?.isAnonSignup || false)
const {t} = useTranslation()
const {signup, signupError} = useOry()
const {signup, upgradeAnon, signupError} = useOry()
const {anonSignup, anonSignupError} = useAnon()
const {isAuthenticated, initialized} = useAuth()
const isAnon = usePlayerStore(state => state.isAnon)
const anonName = usePlayerStore(state => state.name)

// A logged in anon player lands here to upgrade the existing profile instead of creating a new one.
const isUpgrade = isAuthenticated && isAnon

const redirectTo = getValidRedirectPath(location.state?.from)

useEffect(() => {
if (initialized && isAuthenticated) {
if (initialized && isAuthenticated && !isAnon) {
navigate('/lobby', {replace: true})
}
}, [initialized, isAuthenticated, navigate])
}, [initialized, isAuthenticated, isAnon, navigate])

const {execute: executeSignup, isLoading, hasError, reset} = useAsyncAction(async (data: {username: string, email?: string, password?: string}) => {
if (isGuest) {
if (isUpgrade) {
return upgradeAnon({
username: data.username,
email: data.email!,
password: data.password!
}, redirectTo || '/lobby')
}
if (isAnonSignup) {
return anonSignup(data.username, redirectTo)
}
return signup({
Expand Down Expand Up @@ -66,7 +79,10 @@ export function SignupForm() {
return (
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">{t('auth.signup.title')}</CardTitle>
<CardTitle className="text-xl">{t(isUpgrade ? 'auth.upgrade.title' : 'auth.signup.title')}</CardTitle>
{isUpgrade && (
<p className="text-sm text-muted-foreground">{t('auth.upgrade.description')}</p>
)}
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit}>
Expand All @@ -88,34 +104,40 @@ export function SignupForm() {
name="username"
id="username"
type="text"
defaultValue={isUpgrade ? anonName : undefined}
required
/>
{isUpgrade && (
<p className="text-xs text-muted-foreground">{t('auth.upgrade.usernameHint')}</p>
)}
{(signupError?.field == "traits.name" || anonSignupError) && (
<p className="text-sm font-medium text-destructive">
{signupError?.text || anonSignupError?.text}
</p>
)}
</div>

<div className="flex items-center space-x-2">
<Switch
id="guest-mode"
checked={isGuest}
onCheckedChange={setIsGuest}
/>
<Label htmlFor="guest-mode">{t('auth.signup.guestProfile')}</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="h-4 w-4 text-muted-foreground"/>
</TooltipTrigger>
<TooltipContent>
<p className="whitespace-pre-line">{t('auth.signup.guestProfileInfo')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
{!isGuest && (
{!isUpgrade && (
<div className="flex items-center space-x-2">
<Switch
id="anon-mode"
checked={isAnonSignup}
onCheckedChange={setIsAnonSignup}
/>
<Label htmlFor="anon-mode">{t('auth.signup.anonProfile')}</Label>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<HelpCircle className="h-4 w-4 text-muted-foreground"/>
</TooltipTrigger>
<TooltipContent>
<p className="whitespace-pre-line">{t('auth.signup.anonProfileInfo')}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
{(!isAnonSignup || isUpgrade) && (
<>
<div className="grid gap-2">
<Label htmlFor="email">{t('auth.form.email')}</Label>
Expand Down Expand Up @@ -158,15 +180,21 @@ export function SignupForm() {
variant={hasError ? "destructive" : "default"}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('auth.signup.submit')}
{t(isUpgrade ? 'auth.upgrade.submit' : 'auth.signup.submit')}
</Button>

<div className="text-center text-sm">
{t("auth.login.prompt")}&nbsp;
<Link to="/login" state={{from: location.state?.from}} className="underline underline-offset-4">
{t("auth.login.link")}
</Link>
</div>
{isUpgrade ? (
<Button type="button" variant="ghost" onClick={() => navigate('/lobby')}>
{t("auth.upgrade.cancel")}
</Button>
) : (
<div className="text-center text-sm">
{t("auth.login.prompt")}&nbsp;
<Link to="/login" state={{from: location.state?.from}} className="underline underline-offset-4">
{t("auth.login.link")}
</Link>
</div>
)}
</div>
</form>
</CardContent>
Expand Down
51 changes: 47 additions & 4 deletions client/src/hooks/use-ory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import {useNavigate} from "react-router-dom"
import {usePlayerStore} from "@/store/player.ts";
import {useAxiosErrorHandler} from "@/hooks/use-axios-error-handler.tsx";
import {ory} from "@/api/ory.ts";
import {api} from "@/api/client.ts";
import {ErrorMessage, getOryErrorMessage} from "@/api/helpers.ts";
import {UiNode} from "@ory/client";
import {UiNode, UiText} from "@ory/client";
import {useState} from "react";
import {useTranslation} from "react-i18next";

Expand Down Expand Up @@ -46,7 +47,7 @@ export const useOry = () => {
logout()
})

const signup = (credentials: { email: string; password: string; username: string }, redirectTo?: string) =>
const register = (credentials: { email: string; password: string; username: string }) =>
ory.createBrowserRegistrationFlow()
.then(flow => ory.updateRegistrationFlow({
flow: flow.data.id,
Expand All @@ -60,6 +61,9 @@ export const useOry = () => {
csrf_token: getCsrfToken(flow.data)
}
}))

const signup = (credentials: { email: string; password: string; username: string }, redirectTo?: string) =>
register(credentials)
.then(response => {
const oryUuid = response.data.identity.id
const username = response.data.identity.traits.name
Expand All @@ -71,5 +75,44 @@ export const useOry = () => {
handleError(error)
})

return {login, loginError, signup, signupError}
}
const currentIdentity = () =>
ory.toSession().then(response => response.data.identity).catch(() => undefined)

const oryLogout = () =>
ory.createBrowserLogoutFlow().then(flow => ory.updateLogoutFlow({token: flow.data.logout_token}))

/**
* Turns the current anon player into a real account. Ory registration gives us the session cookie, the
* link call then moves the existing player row over to that identity so uuid, name and game
* history are kept.
*
* An anon player can still be holding an ory session, since AuthMiddleware prefers the anon token over it.
* Kratos refuses to register while a session exists, so we either reuse that session when it is the
* account being created (an upgrade that registered but never linked) or drop it and start over.
*/
const upgradeAnon = async (credentials: { email: string; password: string; username: string }, redirectTo?: string) => {
try {
let identity = await currentIdentity()

if (identity?.traits.email !== credentials.email) {
if (identity) await oryLogout()
identity = (await register(credentials)).data.identity
}

await api.anonLink()
setOryPlayer(identity!.id, identity!.traits.name)
navigate(redirectTo || '/')
} catch (error) {
const response = (error as { response?: { status?: number, data?: { ui?: { messages?: UiText[], nodes?: UiNode[] } } } }).response

if (response?.data?.ui) {
setSignupError(getOryErrorMessage(response.data, t))
} else {
setSignupError({id: 0, text: t("errors.upgradeFailed")})
}
handleError(error)
}
}

return {login, loginError, signup, upgradeAnon, signupError}
}
Loading
Loading