Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Deleting Anonymous boards #292

Merged
merged 4 commits into from
Aug 13, 2021
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,10 @@ When using the Docker deployment, your database runs from a container. But if yo

## Versions History

### Version 4.7.0 (not released)

- Add the ability for anonymous users to delete the boards they created under certain conditions ([#229](https:/antoinejaussoin/retro-board/issues/229)).

### Version 4.6.0

- Support OKTA for authentication
Expand Down
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
"socket.io-redis": "6.1.1",
"stripe": "^8.168.0",
"ts-jest": "^27.0.4",
"ts-node": "^10.2.0",
"ts-node": "^9.1.1",
"typeorm": "^0.2.36",
"uuid": "^8.3.2",
"yargs": "^17.1.0"
Expand Down
37 changes: 27 additions & 10 deletions backend/src/auth/logins/anonymous-user.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,36 @@
import { UserEntity } from '../../db/entities';
import { v4 } from 'uuid';
import { getUserByUsername, getOrSaveUser } from '../../db/actions/users';
import {
getUserByUsername,
getOrSaveUser,
updateUserPassword,
} from '../../db/actions/users';
import { hashPassword } from '../../utils';
import { compare } from 'bcryptjs';

export default async function loginAnonymous(
username: string
): Promise<UserEntity> {
username: string,
password: string
): Promise<UserEntity | null> {
const actualUsername = username.split('^')[0];
const existingUser = await getUserByUsername(username);
if (existingUser) {
return existingUser;
if (!existingUser) {
const hashedPassword = await hashPassword(password);
const user = new UserEntity(v4(), actualUsername, hashedPassword);
user.username = username;
user.language = 'en';

const dbUser = await getOrSaveUser(user);
return dbUser;
}

if (!existingUser.password) {
const hashedPassword = await hashPassword(password);
const dbUser = await updateUserPassword(existingUser.id, hashedPassword);
return dbUser;
}
const user = new UserEntity(v4(), actualUsername);
user.username = username;
user.language = 'en';

const dbUser = await getOrSaveUser(user);
return dbUser;
const isPasswordCorrect = await compare(password, existingUser.password);

return isPasswordCorrect ? existingUser : null;
}
18 changes: 13 additions & 5 deletions backend/src/auth/passport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import {
import { v4 } from 'uuid';
import { AccountType } from '@retrospected/common';
import chalk from 'chalk';
import loginAnonymous from './logins/anonymous-user';
import loginUser from './logins/password-user';
import loginAnonymous from './logins/anonymous-user';
import UserEntity from '../db/entities/User';
import {
BaseProfile,
Expand Down Expand Up @@ -195,12 +195,20 @@ export default () => {
options?: IVerifyOptions
) => void
) => {
if (password && password !== '<<<<<NONE>>>>>') {
if (
username.startsWith('ANONUSER__') &&
username.endsWith('__ANONUSER')
) {
// Anonymouns login
const actualUsername = username
.replace('ANONUSER__', '')
.replace('__ANONUSER', '');
const user = await loginAnonymous(actualUsername, password);
done(!user ? 'Anonymous account not valid' : null, user?.id);
} else {
// Regular account login
const user = await loginUser(username, password);
done(!user ? 'User cannot log in' : null, user?.id);
} else {
const user = await loginAnonymous(username);
done(null, user.id);
}
}
)
Expand Down
16 changes: 10 additions & 6 deletions backend/src/db/actions/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ import {
import { orderBy } from 'lodash';
import { transaction } from './transaction';
import { EntityManager } from 'typeorm';
import { isUserPro } from './users';
import { getUserViewInner, isUserPro } from './users';
import { ALL_FIELDS } from '../entities/User';

export async function createSession(
author: UserEntity,
Expand Down Expand Up @@ -186,14 +187,16 @@ export async function deleteSessions(
return await transaction(async (manager) => {
const sessionRepository = manager.getCustomRepository(SessionRepository);
const session = await sessionRepository.findOne(sessionId);
const user = await getUserViewInner(manager, userId);
if (!user) {
console.info('User not found', userId);
return false;
}
if (!session) {
console.info('Session not found', sessionId);
return false;
}
if (
session.createdBy.id !== userId ||
session.createdBy.accountType === 'anonymous'
) {
if (session.createdBy.id !== userId || !user.canDeleteSession) {
console.error(
'The user is not the one who created the session, or is anonymous'
);
Expand Down Expand Up @@ -254,6 +257,7 @@ export async function previousSessions(
const userRepository = manager.getCustomRepository(UserRepository);
const loadedUser = await userRepository.findOne(userId, {
relations: ['sessions', 'sessions.posts', 'sessions.visitors'],
select: ALL_FIELDS,
});
if (loadedUser && loadedUser.sessions) {
return orderBy(loadedUser.sessions, (s) => s.updated, 'desc').map(
Expand All @@ -276,7 +280,7 @@ export async function previousSessions(
participants: getParticipants(session.visitors),
canBeDeleted:
userId === session.createdBy.id &&
session.createdBy.accountType !== 'anonymous',
(loadedUser.accountType !== 'anonymous' || !!loadedUser.password),
} as SessionMetadata)
);
}
Expand Down
21 changes: 20 additions & 1 deletion backend/src/db/actions/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export async function getUserView(id: string): Promise<UserView | null> {
});
}

async function getUserViewInner(
export async function getUserViewInner(
manager: EntityManager,
id: string
): Promise<UserView | null> {
Expand Down Expand Up @@ -96,6 +96,25 @@ export async function getOrSaveUser(user: UserEntity): Promise<UserEntity> {
});
}

export async function updateUserPassword(
id: string,
password: string
): Promise<UserEntity | null> {
return await transaction(async (manager) => {
const userRepository = manager.getCustomRepository(UserRepository);
const existingUser = await userRepository.findOne({
where: { id },
});
if (existingUser) {
return await userRepository.save({
...existingUser,
password,
});
}
return null;
});
}

export function isUserPro(user: FullUser) {
// TODO: deduplicate from same logic in Frontend frontend/src/auth/useIsPro.ts
if (isSelfHostedAndLicenced()) {
Expand Down
5 changes: 5 additions & 0 deletions backend/src/db/entities/UserView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ select
u.photo,
u.language,
u.email,
case when u."accountType" = 'anonymous' and u.password is null then false else true end as "canDeleteSession",
u.trial,
s.id as "ownSubscriptionsId",
s.plan as "ownPlan",
Expand All @@ -39,6 +40,8 @@ export default class UserView {
@ViewColumn()
public email: string | null;
@ViewColumn()
public canDeleteSession: boolean;
@ViewColumn()
public stripeId: string | null;
@ViewColumn()
public photo: string | null;
Expand Down Expand Up @@ -72,6 +75,7 @@ export default class UserView {
this.subscriptionsId = null;
this.pro = false;
this.email = null;
this.canDeleteSession = false;
this.currency = null;
this.ownPlan = null;
this.ownSubscriptionsId = null;
Expand All @@ -86,6 +90,7 @@ export default class UserView {
name: this.name,
photo: this.photo,
email: this.email,
canDeleteSession: this.canDeleteSession,
pro: this.pro,
subscriptionsId: this.subscriptionsId,
accountType: this.accountType,
Expand Down
65 changes: 65 additions & 0 deletions backend/src/db/migrations/1628773645790-CanDeleteSessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {MigrationInterface, QueryRunner} from "typeorm";

export class CanDeleteSessions1628773645790 implements MigrationInterface {
name = 'CanDeleteSessions1628773645790'

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "schema" = $2 AND "name" = $3`, ["VIEW","public","user_view"]);
await queryRunner.query(`DROP VIEW "public"."user_view"`);
await queryRunner.query(`CREATE VIEW "user_view" AS
select
u.id,
u.name,
u."accountType",
u.username,
u.currency,
u."stripeId",
u.photo,
u.language,
u.email,
case when u."accountType" = 'anonymous' and u.password is null then false else true end as "canDeleteSession",
u.trial,
s.id as "ownSubscriptionsId",
s.plan as "ownPlan",
coalesce(s.id, s2.id, s3.id) as "subscriptionsId",
coalesce(s.active, s2.active, s3.active, false) as "pro",
coalesce(s.plan, s2.plan, s3.plan) as "plan",
coalesce(s.domain, s2.domain, s3.domain) as "domain"
from users u

left join subscriptions s on s."ownerId" = u.id and s.active is true
left join subscriptions s2 on lower(u.email) = any(lower(s2.members::text)::text[]) and s2.active is true
left join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true
`);
await queryRunner.query(`INSERT INTO "typeorm_metadata"("type", "schema", "name", "value") VALUES ($1, $2, $3, $4)`, ["VIEW","public","user_view","select \n u.id,\n u.name,\n u.\"accountType\",\n u.username,\n u.currency,\n u.\"stripeId\",\n u.photo,\n u.language,\n u.email,\n case when u.\"accountType\" = 'anonymous' and u.password is null then false else true end as \"canDeleteSession\",\n u.trial,\n s.id as \"ownSubscriptionsId\",\n s.plan as \"ownPlan\",\n coalesce(s.id, s2.id, s3.id) as \"subscriptionsId\",\n coalesce(s.active, s2.active, s3.active, false) as \"pro\",\n coalesce(s.plan, s2.plan, s3.plan) as \"plan\",\n coalesce(s.domain, s2.domain, s3.domain) as \"domain\"\nfrom users u \n\nleft join subscriptions s on s.\"ownerId\" = u.id and s.active is true\nleft join subscriptions s2 on lower(u.email) = any(lower(s2.members::text)::text[]) and s2.active is true\nleft join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true"]);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "schema" = $2 AND "name" = $3`, ["VIEW","public","user_view"]);
await queryRunner.query(`DROP VIEW "user_view"`);
await queryRunner.query(`CREATE VIEW "public"."user_view" AS select
u.id,
u.name,
u."accountType",
u.username,
u.currency,
u."stripeId",
u.photo,
u.language,
u.email,
u.trial,
s.id as "ownSubscriptionsId",
s.plan as "ownPlan",
coalesce(s.id, s2.id, s3.id) as "subscriptionsId",
coalesce(s.active, s2.active, s3.active, false) as "pro",
coalesce(s.plan, s2.plan, s3.plan) as "plan",
coalesce(s.domain, s2.domain, s3.domain) as "domain"
from users u

left join subscriptions s on s."ownerId" = u.id and s.active is true
left join subscriptions s2 on lower(u.email) = any(lower(s2.members::text)::text[]) and s2.active is true
left join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true`);
await queryRunner.query(`INSERT INTO "typeorm_metadata"("type", "schema", "name", "value") VALUES ($1, $2, $3, $4)`, ["VIEW","public","user_view","select \n u.id,\n u.name,\n u.\"accountType\",\n u.username,\n u.currency,\n u.\"stripeId\",\n u.photo,\n u.language,\n u.email,\n u.trial,\n s.id as \"ownSubscriptionsId\",\n s.plan as \"ownPlan\",\n coalesce(s.id, s2.id, s3.id) as \"subscriptionsId\",\n coalesce(s.active, s2.active, s3.active, false) as \"pro\",\n coalesce(s.plan, s2.plan, s3.plan) as \"plan\",\n coalesce(s.domain, s2.domain, s3.domain) as \"domain\"\nfrom users u \n\nleft join subscriptions s on s.\"ownerId\" = u.id and s.active is true\nleft join subscriptions s2 on lower(u.email) = any(lower(s2.members::text)::text[]) and s2.active is true\nleft join subscriptions s3 on s3.domain = split_part(u.email, '@', 2) and s3.active is true"]);
}

}
2 changes: 1 addition & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ db().then(() => {
app.delete('/api/session/:sessionId', heavyLoadLimiter, async (req, res) => {
const sessionId = req.params.sessionId;
const user = await getUserFromRequest(req);
if (user && user.accountType !== 'anonymous') {
if (user) {
const success = await deleteSessions(user.id, sessionId);
cache.invalidate(user.id);
if (success) {
Expand Down
1 change: 1 addition & 0 deletions common/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ export interface FullUser extends User {
accountType: AccountType;
language: string;
email: string | null;
canDeleteSession: boolean;
stripeId: string | null;
pro: boolean;
subscriptionsId: string | null;
Expand Down
18 changes: 15 additions & 3 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,12 @@ export async function anonymousLogin(
username: string
): Promise<FullUser | null> {
const anonymousUsername = getAnonymousUsername(username);
const success = await fetchPost('/api/auth/anonymous/login', {
username: anonymousUsername,
password: '<<<<<NONE>>>>>',
const password = getAnonUserPassword(anonymousUsername);
const success = await fetchPost('/api/auth/login', {
username: `ANONUSER__${anonymousUsername}__ANONUSER`,
password,
});

if (success) {
return me();
}
Expand Down Expand Up @@ -180,6 +182,16 @@ function getAnonymousUsername(username: string): string {
return storedUsername;
}

function getAnonUserPassword(username: string) {
const key = `anonymous-password-${username}`;
let password = getItem(key);
if (!password) {
password = v4();
setItem(key, password);
}
return password;
}

export async function updateLanguage(
language: string
): Promise<FullUser | null> {
Expand Down
14 changes: 12 additions & 2 deletions frontend/src/auth/modal/AnonAuth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,18 @@ interface AnonAuthProps {
const AnonAuth = ({ onClose, onUser }: AnonAuthProps) => {
const { AnonymousLogin: loginTranslations } = useTranslations();
const language = useLanguage();

const [username, setUsername] = useState('');
const [error, setError] = useState('');

const handleAnonLogin = useCallback(() => {
async function login() {
const trimmedUsername = username.trim();
if (trimmedUsername.length) {
await anonymousLogin(trimmedUsername);
const user = await anonymousLogin(trimmedUsername);
if (!user) {
setError('Your anonymous account is not valid.');
return;
}
const updatedUser = await updateLanguage(language.value);
onUser(updatedUser);
if (onClose) {
Expand Down Expand Up @@ -53,6 +58,11 @@ const AnonAuth = ({ onClose, onUser }: AnonAuthProps) => {
<Alert severity="info">
{loginTranslations.anonymousAuthDescription}
</Alert>
{!!error ? (
<Alert severity="error" style={{ marginTop: 10 }}>
{error}
</Alert>
) : null}
<Input
value={username}
onChange={handleUsernameChange}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/testing/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const Inner: React.FC = ({ children }) => {
ownPlan: null,
ownSubscriptionsId: null,
trial: null,
canDeleteSession: false,
});
useEffect(() => {
receiveBoard(initialSession);
Expand All @@ -76,6 +77,7 @@ const Inner: React.FC = ({ children }) => {
ownPlan: null,
ownSubscriptionsId: null,
trial: null,
canDeleteSession: false,
});
}, [receiveBoard]);
return (
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/views/home/game-item/PreviousGameItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ const PreviousGameItem = ({
<Top>
<LastUpdated>
{formatDistanceToNow(
Date.parse((session.created as unknown) as string),
Date.parse(session.created as unknown as string),
true
)}
&nbsp;
Expand Down
Loading