Skip to content

Commit

Permalink
Fix login.
Browse files Browse the repository at this point in the history
  • Loading branch information
SebastianStehle committed Sep 26, 2024
1 parent f416318 commit 18f60bb
Show file tree
Hide file tree
Showing 9 changed files with 85 additions and 62 deletions.
1 change: 0 additions & 1 deletion .github/workflows/dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ jobs:
uses: docker/build-push-action@v6.2.0
with:
load: true
build-args: "NOTIFO__RUNTIME__VERSION=1.0.0-dev-${{ env.BUILD_NUMBER }}"
cache-from: type=gha
cache-to: type=gha,mode=max
tags: notifo-local
Expand Down
2 changes: 2 additions & 0 deletions backend/src/Notifo.Domain/Identity/IUser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public interface IUser

object Identity { get; }

bool HasLoginOrPassword { get; }

IReadOnlySet<string> Roles { get; }

IReadOnlyList<Claim> Claims { get; }
Expand Down
9 changes: 5 additions & 4 deletions backend/src/Notifo.Identity/DefaultUserService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,6 @@ public async Task<IUser> CreateAsync(string email, UserValues? values = null, bo
Guard.NotNullOrEmpty(email);

var user = userFactory.Create(email);

try
{
var isFirst = !userManager.Users.Any();
Expand Down Expand Up @@ -391,11 +390,13 @@ private Task<IUser[]> ResolveAsync(IEnumerable<IdentityUser> users)

private async Task<IUser> ResolveAsync(IdentityUser user)
{
var (claims, roles) = await AsyncHelper.WhenAll(
var (claims, roles, logins, hasPassword) = await AsyncHelper.WhenAll(
userManager.GetClaimsAsync(user),
userManager.GetRolesAsync(user));
userManager.GetRolesAsync(user),
userManager.GetLoginsAsync(user),
userManager.HasPasswordAsync(user));

return new UserWithClaims(user, claims.ToList(), roles.ToHashSet());
return new UserWithClaims(user, claims.ToList(), roles.ToHashSet(), logins.Any() || hasPassword);
}

private async Task<IUser?> ResolveOptionalAsync(IdentityUser? user)
Expand Down
30 changes: 11 additions & 19 deletions backend/src/Notifo.Identity/UserWithClaims.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,15 @@

namespace Notifo.Identity;

internal sealed class UserWithClaims : IUser
internal sealed class UserWithClaims(
IdentityUser user,
IReadOnlyList<Claim> claims,
IReadOnlySet<string> roles,
bool hasLoginOrPassword) : IUser
{
private readonly IdentityUser snapshot;
private readonly IdentityUser snapshot = SimpleMapper.Map(user, new IdentityUser());

public IdentityUser Identity { get; }
public IdentityUser Identity { get; } = user;

public string Id
{
Expand All @@ -33,23 +37,11 @@ public bool IsLocked
get => snapshot.LockoutEnd > DateTimeOffset.UtcNow;
}

public IReadOnlyList<Claim> Claims { get; }
public bool HasLoginOrPassword { get; } = hasLoginOrPassword;

public IReadOnlySet<string> Roles { get; }
public IReadOnlyList<Claim> Claims { get; } = claims;

object IUser.Identity => Identity;

public UserWithClaims(IdentityUser user, IReadOnlyList<Claim> claims, IReadOnlySet<string> roles)
{
Identity = user;

// Clone the user so that we capture the previous values, even when the user is updated.
snapshot = SimpleMapper.Map(user, new IdentityUser());
public IReadOnlySet<string> Roles { get; } = roles;

// Claims are immutable so we do not need a copy of them.
Claims = claims;

// Roles are immutable so we do not need a copy of them.
Roles = roles;
}
object IUser.Identity => Identity;
}
9 changes: 9 additions & 0 deletions backend/src/Notifo.Infrastructure/Tasks/AsyncHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ public static class AsyncHelper

#pragma warning disable MA0042 // Do not use blocking calls in an async method
return (task1.Result, task2.Result, task3.Result);
#pragma warning restore MA0042 // Do not use blocking calls in an async method
}

public static async Task<(T1, T2, T3, T4)> WhenAll<T1, T2, T3, T4>(Task<T1> task1, Task<T2> task2, Task<T3> task3, Task<T4> task4)
{
await Task.WhenAll(task1, task2, task3, task4);

#pragma warning disable MA0042 // Do not use blocking calls in an async method
return (task1.Result, task2.Result, task3.Result, task4.Result);
#pragma warning restore MA0042 // Do not use blocking calls in an async method
}
}
12 changes: 10 additions & 2 deletions backend/src/Notifo/Areas/Account/Pages/ExternalLogin.cshtml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,16 @@ public async Task<IActionResult> OnPostConfirmation()
IUser user;
try
{
user = await UserService.CreateAsync(email, ct: HttpContext.RequestAborted);
var byEmail = await UserService.FindByEmailAsync(email, HttpContext.RequestAborted);
// If the user has no login it has probably been created when he was invited to an app and we can assign it.
if (byEmail?.HasLoginOrPassword == false)
{
user = byEmail;
}
else
{
user = await UserService.CreateAsync(email, ct: HttpContext.RequestAborted);
}

await UserService.AddLoginAsync(user.Id, loginInfo, HttpContext.RequestAborted);
}
Expand All @@ -169,7 +178,6 @@ public async Task<IActionResult> OnPostConfirmation()
}

await SignInManager.SignInAsync((IdentityUser)user.Identity, false);

return RedirectTo(ReturnUrl);
}

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/pages/apps/AppDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const AppDialog = (props: AppDialogProps) => {
<FormAlert text={texts.apps.createInfo} />

<fieldset className='mt-3' disabled={createRunning}>
<Forms.Text name='name' vertical
<Forms.Text name='name' vertical autoFocus
label={texts.common.name} />
</fieldset>

Expand Down
61 changes: 32 additions & 29 deletions frontend/src/app/shared/components/Forms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export interface FormEditorProps {
// The label.
label?: string;

// Indicates that the input should be focused automatically.
autoFocus?: boolean;

// The optional class name.
className?: string;

Expand Down Expand Up @@ -169,67 +172,67 @@ export module Forms {
);
};

export const Text = ({ placeholder, ...other }: FormEditorProps) => {
export const Text = ({ autoFocus, placeholder, ...other }: FormEditorProps) => {
return (
<Forms.Row {...other}>
<InputText name={other.name} placeholder={placeholder} />
<InputText name={other.name} placeholder={placeholder} autoFocus={autoFocus} />
</Forms.Row>
);
};

export const Phone = ({ placeholder, ...other }: FormEditorProps) => {
export const Phone = ({ autoFocus, placeholder, ...other }: FormEditorProps) => {
return (
<Forms.Row {...other}>
<InputSpecial type='tel' name={other.name} placeholder={placeholder} />
<InputSpecial type='tel' name={other.name} placeholder={placeholder} autoFocus={autoFocus} />
</Forms.Row>
);
}
};

export const Url = ({ placeholder, ...other }: FormEditorProps) => {
export const Url = ({ autoFocus, placeholder, ...other }: FormEditorProps) => {
return (
<Forms.Row {...other}>
<InputSpecial type='url' name={other.name} placeholder={placeholder} />
<InputSpecial type='url' name={other.name} placeholder={placeholder} autoFocus={autoFocus} />
</Forms.Row>
);
};

export const Email = ({ placeholder, ...other }: FormEditorProps) => {
export const Email = ({ autoFocus, placeholder, ...other }: FormEditorProps) => {
return (
<Forms.Row {...other}>
<InputSpecial type='email' name={other.name} placeholder={placeholder} />
<InputSpecial type='email' name={other.name} placeholder={placeholder} autoFocus={autoFocus} />
</Forms.Row>
);
};

export const Time = ({ placeholder, ...other }: FormEditorProps) => {
export const Time = ({ autoFocus, placeholder, ...other }: FormEditorProps) => {
return (
<Forms.Row {...other}>
<InputSpecial type='time' name={other.name} placeholder={placeholder} />
<InputSpecial type='time' name={other.name} placeholder={placeholder} autoFocus={autoFocus} />
</Forms.Row>
);
};

export const Date = ({ placeholder, ...other }: FormEditorProps) => {
export const Date = ({ autoFocus, placeholder, ...other }: FormEditorProps) => {
return (
<Forms.Row {...other}>
<InputSpecial type='date' name={other.name} placeholder={placeholder} />
<InputSpecial type='date' name={other.name} placeholder={placeholder} autoFocus={autoFocus} />
</Forms.Row>
);
};

export const Textarea = ({ placeholder, ...other }: FormEditorProps) => {
export const Textarea = ({ autoFocus, placeholder, ...other }: FormEditorProps) => {
return (
<Forms.Row {...other}>
<InputTextarea name={other.name} placeholder={placeholder} />
<InputTextarea name={other.name} placeholder={placeholder} autoFocus={autoFocus} />
</Forms.Row>
);
};

export const Number = ({ max, min, placeholder, step, unit, ...other }: FormEditorProps & { unit?: string; min?: number; max?: number; step?: number }) => {
export const Number = ({ autoFocus, max, min, placeholder, step, unit, ...other }: FormEditorProps & { unit?: string; min?: number; max?: number; step?: number }) => {
return (
<Forms.Row {...other}>
<InputGroup>
<InputNumber name={other.name} placeholder={placeholder} max={max} min={min} step={step} />
<InputNumber name={other.name} placeholder={placeholder} max={max} min={min} step={step} autoFocus={autoFocus} />

{unit &&
<InputGroupAddon addonType='prepend'>
Expand All @@ -241,10 +244,10 @@ export module Forms {
);
};

export const Password = (props: FormEditorProps) => {
export const Password = ({ autoFocus, placeholder, ...other }: FormEditorProps) => {
return (
<Forms.Row {...props}>
<InputPassword name={props.name} placeholder={props.placeholder} />
<Forms.Row {...other}>
<InputPassword name={other.name} placeholder={placeholder} autoFocus={autoFocus} />
</Forms.Row>
);
};
Expand Down Expand Up @@ -282,7 +285,7 @@ const FormDescription = ({ hints }: { hints?: string }) => {
);
};

const InputText = ({ name, picker, placeholder }: FormEditorProps) => {
const InputText = ({ autoFocus, name, picker, placeholder }: FormEditorProps) => {
const { field, fieldState, formState } = useController({ name });

const doAddPick = useEventCallback((pick: string) => {
Expand All @@ -295,14 +298,14 @@ const InputText = ({ name, picker, placeholder }: FormEditorProps) => {
<Picker {...picker} onPick={doAddPick} value={field.value} />
}

<Input type='text' id={name} {...field} invalid={isInvalid(fieldState, formState)}
<Input type='text' id={name} {...field} invalid={isInvalid(fieldState, formState)} autoFocus={autoFocus}
placeholder={placeholder}
/>
</div>
);
};

const InputTextarea = ({ name, picker, placeholder }: FormEditorProps) => {
const InputTextarea = ({ autoFocus, name, picker, placeholder }: FormEditorProps) => {
const { field, fieldState, formState } = useController({ name });

const doAddPick = useEventCallback((pick: string) => {
Expand All @@ -315,31 +318,31 @@ const InputTextarea = ({ name, picker, placeholder }: FormEditorProps) => {
<Picker {...picker} onPick={doAddPick} value={field.value} />
}

<Input type='textarea' id={name} {...field} invalid={isInvalid(fieldState, formState)}
<Input type='textarea' id={name} {...field} invalid={isInvalid(fieldState, formState)} autoFocus={autoFocus}
placeholder={placeholder}
/>
</div>
);
};

const InputNumber = ({ name, max, min, placeholder, step }: FormEditorProps & { min?: number; max?: number; step?: number }) => {
const InputNumber = ({ autoFocus, name, max, min, placeholder, step }: FormEditorProps & { min?: number; max?: number; step?: number }) => {
const { field, fieldState, formState } = useController({ name });

return (
<>
<Input type='number' id={name} {...field} invalid={isInvalid(fieldState, formState)}
<Input type='number' id={name} {...field} invalid={isInvalid(fieldState, formState)} autoFocus={autoFocus}
max={max} min={min} step={step} placeholder={placeholder}
/>
</>
);
};

const InputSpecial = ({ name, placeholder, type }: FormEditorProps & { type: InputType }) => {
const InputSpecial = ({ autoFocus, name, placeholder, type }: FormEditorProps & { type: InputType }) => {
const { field, fieldState, formState } = useController({ name });

return (
<>
<Input type={type} id={name} {...field} invalid={isInvalid(fieldState, formState)}
<Input type={type} id={name} {...field} invalid={isInvalid(fieldState, formState)} autoFocus={autoFocus}
placeholder={placeholder}
/>
</>
Expand All @@ -350,7 +353,7 @@ const InputPassword = ({ name, placeholder }: FormEditorProps) => {

return (
<>
<PasswordInput id={name} {...field} invalid={isInvalid(fieldState, formState)}
<PasswordInput id={name} {...field} invalid={isInvalid(fieldState, formState)} autoFocus={autoFocus}
placeholder={placeholder}
/>
</>
Expand Down
21 changes: 15 additions & 6 deletions frontend/vite.build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,20 @@ const dirName = fileURLToPath(new URL('.', import.meta.url));

const inputs = {
// The actual management app.
['app']: path.resolve(dirName, 'index.html'),
['app']: {
entry: path.resolve(dirName, 'index.html'),
format: undefined,
},
// Build the worker separately to prevent exports.
['notifo-sdk']: path.resolve(dirName, 'src/sdk/sdk.ts'),
['notifo-sdk']: {
entry: path.resolve(dirName, 'src/sdk/sdk.ts'),
format: 'iife',
},
// Build the worker separately so that it does not get any file.
['notifo-sdk-worker']: path.resolve(dirName, 'src/sdk/sdk-worker.ts'),
['notifo-sdk-worker']: {
entry: path.resolve(dirName, 'src/sdk/sdk-worker.ts'),
format: 'iife',
},
};

defaultConfig.plugins.push(
Expand All @@ -40,18 +49,18 @@ defaultConfig.plugins.push(
async function buildPackages() {
await rimraf('./build');

for (const [chunk, source] of Object.entries(inputs)) {
for (const [chunk, config] of Object.entries(inputs)) {
// https://vitejs.dev/config/
await build({
publicDir: false,
build: {
outDir: 'build',
rollupOptions: {
input: {
[chunk]: source,
[chunk]: config.entry,
},
output: {
format: 'iife',
format: config.format,

entryFileNames: chunk => {
return `${chunk.name}.js`;
Expand Down

0 comments on commit 18f60bb

Please sign in to comment.