Skip to content

Commit

Permalink
Deleting Anonymous boards (#292)
Browse files Browse the repository at this point in the history
  • Loading branch information
antoinejaussoin committed Sep 19, 2021
1 parent 113a5f9 commit a7385e3
Show file tree
Hide file tree
Showing 15 changed files with 185 additions and 81 deletions.
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://github.com/antoinejaussoin/retro-board/issues/229)).

### Version 4.6.1

- Fixing a typo. 👏 Thanks Chad S.! ([#296](https://github.com/antoinejaussoin/retro-board/issues/296))
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

0 comments on commit a7385e3

Please sign in to comment.