Skip to content

Commit

Permalink
HOSTSD-254 Add preapproved user (#111)
Browse files Browse the repository at this point in the history
  • Loading branch information
Fosol authored Mar 8, 2024
1 parent 907a49a commit 5c755d8
Show file tree
Hide file tree
Showing 13 changed files with 222 additions and 90 deletions.
1 change: 1 addition & 0 deletions EMERALD.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Before running these commands you will need to create and populate configuration

There is a folder for each environment, and within each folder it should contain the following files.

- ches.env
- css.env
- dashboard.env
- database.env
Expand Down
7 changes: 5 additions & 2 deletions src/api/Keycloak/CssHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ private Task AddOrUpdateUserAsync(string username, Entities.User? user, HSB.CSS.
if (userRoles.Users.Length > 1) throw new NotAuthorizedException($"Keycloak has multiple users with the same username '{key}'");
if (user == null)
{
_logger.LogDebug("User activation, New User: {username}:{key}", username, key);
// Add the user to the database.
user = new Entities.User(username, email, key)
{
Expand All @@ -170,9 +171,11 @@ private Task AddOrUpdateUserAsync(string username, Entities.User? user, HSB.CSS.
LastLoginOn = DateTime.UtcNow,
};
_userService.Add(user);
_userService.CommitTransaction();
}
else if (user != null)
{
_logger.LogDebug("User activation, Update User: {username}:{key}", username, key);
// The user was created in HSB initially, but now the user has logged in and activated their account.
user.Username = username;
user.DisplayName = principal.GetDisplayName() ?? user.DisplayName;
Expand All @@ -189,15 +192,15 @@ private Task AddOrUpdateUserAsync(string username, Entities.User? user, HSB.CSS.
// Apply the preapproved roles to the user.
var roles = await UpdateUserRolesAsync(key.ToString(), preapprovedRoles);
_userService.Update(user);
return user;
_userService.CommitTransaction();
}
}
else
{
user.LastLoginOn = DateTime.UtcNow;
_userService.Update(user);
_userService.CommitTransaction();
}
_userService.CommitTransaction();

return user;
}
Expand Down
6 changes: 4 additions & 2 deletions src/dashboard/src/app/api/auth/authOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,10 @@ export const authOptions: AuthOptions = {
const aToken = token as any;

if (trigger === 'update') {
token.roles = session.user.roles;
return token;
const refreshToken = await refreshAccessToken(token);
refreshToken.roles = session.user.roles;
refreshToken.decoded = jwtDecode(`${refreshToken.access_token}`) as any;
return refreshToken;
} else {
if (account && user) {
token.access_token = account.access_token;
Expand Down
6 changes: 5 additions & 1 deletion src/dashboard/src/app/hsb/admin/users/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ import { EditUserRow } from './EditUserRow';
import { defaultUser } from './defaultUser';
import { validateUser } from './validateUser';

/**
* Provides a page component which displays all users so that the admin can modify their permissions.
* @returns Component
*/
export default function Page() {
const state = useAuth();
const { isReady: isReadyUsers, users } = useAdminUsers({ includePermissions: true, init: true });
Expand Down Expand Up @@ -74,7 +78,7 @@ export default function Page() {
}, []);

const handleRemoveUserRow = React.useCallback((userKey: string) => {
setFormUsers((currentUsers) => currentUsers.filter(user => user.key !== userKey));
setFormUsers((currentUsers) => currentUsers.filter((user) => user.key !== userKey));
}, []);

const handleUpdate = React.useCallback(async () => {
Expand Down
10 changes: 9 additions & 1 deletion src/dashboard/src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
'use client';

import { Button } from '@/components';
import { useAuth } from '@/hooks';
import { signIn } from 'next-auth/react';
import styles from './Login.module.scss'
import { redirect } from 'next/navigation';
import styles from './Login.module.scss';

export default function Page() {
const { isAuthorized } = useAuth();

// Need this because after activating a pre-authorized user, they get sent to the login page again for some reason.
// One downside to this is it is impossible to go to the login page when already authorized.
if (isAuthorized) redirect('/');

return (
<div className={`dashboardContainer ${styles.container}`}>
<div className={styles.welcome}>
Expand Down
2 changes: 1 addition & 1 deletion src/dashboard/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useAuth } from '@/hooks';
import { redirect } from 'next/navigation';

export default function Page() {
const { isClient, isHSB, isAuthorized } = useAuth();
const { isClient, isHSB, isAuthorized, session } = useAuth();

// Redirect to default page for each type of user.
if (isClient) redirect('/client/dashboard');
Expand Down
23 changes: 21 additions & 2 deletions src/dashboard/src/app/welcome/Welcome.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@
font-weight: bold;
font-style: italic;
color: $bc-black;

&:hover {
text-decoration: underline;
text-decoration: underline;
}
}
}
Expand All @@ -50,4 +50,23 @@
display: flex;
flex-direction: column;
justify-content: flex-end;
}

.activate {
margin-top: 2rem;
padding: 1rem;
font-size: $font-size-18;
border-radius: 1rem;
background: #E8EAEC;
display: flex;
flex-direction: row;

>div:first-child {
flex: 2;
}

>div:nth-child(2) {
position: relative;
flex: 1;
}
}
33 changes: 31 additions & 2 deletions src/dashboard/src/app/welcome/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,44 @@
'use client';

import { LoadingAnimation } from '@/components';
import { useAuth } from '@/hooks';
import { useAppStore } from '@/store';
import { redirect } from 'next/navigation';
import styles from './Welcome.module.scss';

export default function Page() {
const { isAuthorized } = useAuth();
const userinfo = useAppStore((state) => state.userinfo);

// Need this because after activating a pre-authorized user so that it will redirect to their home page.
if (isAuthorized) redirect('/');

return (
<div className={`dashboardContainer ${styles.container}`}>
<div className={styles.welcome}>
<h2>Request Access</h2>
<p>Please email <a href="mailto:[email protected]">[email protected]</a> to request access to your organization&apos;s dashboard</p>
<p>
Please email <a href="mailto:[email protected]">[email protected]</a> to request
access to your organization&apos;s dashboard
</p>
{!userinfo && (
<div className={styles.activate}>
<div>
Activating account. If account has been pre-approved it will automatically apply roles
and redirect to home page.
</div>
<div>
<LoadingAnimation />
</div>
</div>
)}
{userinfo && !userinfo.roles.length && (
<div className={styles.activate}>Your account has not be pre-approved.</div>
)}
</div>
<div className={styles.login}>
<h2>Welcome to the Storage Dashboard</h2>
<p>Sign in to get insights into your organization&apos;s data storage and usage.</p>
<p>Request access to get insights into your organization&apos;s data storage and usage.</p>
</div>
</div>
);
Expand Down
86 changes: 61 additions & 25 deletions src/dashboard/src/components/auth/AuthState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useAppStore } from '@/store';
import { keycloakSessionLogOut } from '@/utils';
import _ from 'lodash';
import { signIn, useSession } from 'next-auth/react';
import { redirect } from 'next/navigation';
import { useRouter } from 'next/navigation';
import React from 'react';
import { FaSignInAlt, FaSignOutAlt } from 'react-icons/fa';
import { toast } from 'react-toastify';
Expand All @@ -16,39 +16,75 @@ export interface IAuthState {
showName?: boolean;
}

/**
* Activator class provides a thread-safe singleton.
*/
class Activator {
private activating: boolean = false;

constructor() {}

/**
* Only the first request to start will return true.
* @returns Whether to start activating.
*/
start() {
if (!this.activating) {
this.activating = true;
return true;
}
return false;
}

getState() {
return this.activating;
}

setState(state: boolean) {
this.activating = state;
return this.activating;
}
}

let activator = new Activator();

export const AuthState: React.FC<IAuthState> = ({ showName }) => {
const { userinfo: getUserinfo } = useApiUsers();
const { status, session } = useAuth();
const userinfo = useAppStore((state) => state.userinfo);
const setUserinfo = useAppStore((state) => state.setUserinfo);
const { update } = useSession();

// Only ask for userinfo once.
const [init, setInit] = React.useState(true);
const router = useRouter();

React.useEffect(() => {
if (init && !userinfo && status === 'authenticated' && session) {
setInit(false);
getUserinfo()
.then(async (res) => {
const userinfo = await res.json();
setUserinfo(userinfo);
// The user activation process can automatically apply roles to a preapproved user.
const roles = _.uniq(userinfo.groups.concat(session.user.roles));
if (
!session.user.roles.length ||
!userinfo.groups.every((group: string) => roles.includes(group))
) {
update({ ...session, user: { ...session.user, roles: roles } });
redirect('/');
}
})
.catch((error: any) => {
toast.error('Failed to activate user. Try to login again');
console.error('Failed to activate user', error);
});
if (!userinfo && status === 'authenticated' && session) {
if (activator.start()) {
if (session.user.roles.length === 0) toast.info('Attempting to preapprove your account');
getUserinfo()
.then(async (res) => {
const userinfo = await res.json();
// The user activation process can automatically apply roles to a preapproved user.
const roles = _.uniq(userinfo.groups.concat(session.user.roles));
if (
!session.user.roles.length ||
!userinfo.groups.every((group: string) => roles.includes(group))
) {
// Inform the session of the pre-authorized user role changes.
await update({ ...session, user: { ...session.user, roles: roles } });
setUserinfo(userinfo);
router.push('/');
} else {
setUserinfo(userinfo);
}
})
.catch((error: any) => {
toast.error('Failed to activate user. Try to login again');
console.error('Failed to activate user', error);
activator.setState(false);
});
}
}
}, [setUserinfo, status, getUserinfo, session, update, userinfo, init]);
}, [setUserinfo, status, getUserinfo, session, update, userinfo, router]);

if (status === 'loading') return <div>loading...</div>;
else if (status === 'authenticated') {
Expand Down
Loading

0 comments on commit 5c755d8

Please sign in to comment.