Compare commits

...

23 commits

Author SHA1 Message Date
Dominik Korsa
709934b021
Remove debug timeout 2021-02-13 18:25:45 +01:00
Dominik Korsa
9ff7c35fe9
Implement modifying app info 2021-02-13 18:23:19 +01:00
Dominik Korsa
52e9fee664
Add missing fields to application model 2021-02-13 14:57:49 +01:00
Dominik Korsa
730f7d5b34
Rename database collections 2021-02-13 14:30:34 +01:00
Dominik Korsa
d5f69af319
List apps on home page 2021-02-13 14:28:40 +01:00
Dominik Korsa
a2756fadc8
Rename entity classes 2021-02-13 13:40:54 +01:00
Dominik Korsa
f1e42085be
Add application queries 2021-02-13 13:34:29 +01:00
Dominik Korsa
36a93b10d1
Move database config to env variables 2021-02-13 12:38:34 +01:00
Dominik Korsa
b75a31a201
Implement app creation 2021-02-12 21:23:51 +01:00
Dominik Korsa
8f47ee3aec
Create home view 2021-02-12 20:09:54 +01:00
Dominik Korsa
d70905e8c5
Add account menu 2021-02-12 14:00:00 +01:00
Dominik Korsa
b0c0a7202d
Merge branch 'master' into console 2021-02-12 12:24:19 +01:00
Dominik Korsa
0e3957fd45
Implement sign out 2021-02-12 12:22:01 +01:00
Dominik Korsa
cac25472c6
Implement login state in website 2021-02-12 11:48:04 +01:00
Dominik Korsa
a1064b2a10
Add login state query 2021-02-12 11:32:30 +01:00
Dominik Korsa
084f38bcbf
Merge remote-tracking branch 'origin/console' into console 2021-02-12 10:54:21 +01:00
Dominik Korsa
c045fde577
Store login state in session 2021-02-12 10:53:54 +01:00
Dominik Korsa
b47d500331
Update package lock 2021-02-12 09:47:30 +01:00
Dominik Korsa
576f82f029
Start working on GitHub callback 2021-02-12 00:16:54 +01:00
Dominik Korsa
8083bcef8e
Create GitHub sign in redirect 2021-02-11 23:23:47 +01:00
Dominik Korsa
0dd43c08b9
Don't use relations 2021-02-11 21:41:41 +01:00
Dominik Korsa
866ffc55c7
Create developer entity 2021-02-11 20:01:37 +01:00
Dominik Korsa
aff93deef8
Start working on developer console 2021-02-07 14:54:11 +01:00
58 changed files with 2208 additions and 473 deletions

1030
backend/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,7 @@
"lint": "eslint src -c .eslintrc.json --ext .ts",
"lint:fix": "eslint src -c .eslintrc.json --ext .ts --fix",
"start": "ts-node .",
"dev": "ts-node-dev ."
"dev": "nodemon --watch \"src/**\" --ext \"ts,json\" --exec \"npm run start\""
},
"author": "Dominik Korsa <dominik.korsa@gmail.com>",
"license": "MIT",
@ -49,6 +49,6 @@
"eslint-config-airbnb": "^18.2.1",
"eslint-config-airbnb-typescript": "^12.0.0",
"eslint-plugin-import": "^2.22.1",
"ts-node-dev": "^1.1.1"
"nodemon": "^2.0.7"
}
}

View file

@ -1,35 +1,30 @@
import type { Connection, Repository } from 'typeorm';
import type { Connection } from 'typeorm';
import { createConnection } from 'typeorm';
import Application from './entities/application';
import Token from './entities/token';
import User from './entities/user';
import { requireEnv } from '../utils';
import ApplicationEntity from './entities/application';
import ClientEntity from './entities/client';
import DeveloperEntity from './entities/developer';
import TokenEntity from './entities/token';
import UserEntity from './entities/user';
class Database {
private connection!: Connection;
public applicationRepo!: Repository<Application>;
public userRepo!: Repository<User>;
public tokenRepo!: Repository<Token>;
public async connect(): Promise<void> {
this.connection = await createConnection({
type: 'mongodb',
host: 'localhost',
port: 27017,
database: 'wulkanowy-bridge-local',
url: requireEnv('DATABASE_URL'),
useNewUrlParser: true,
entities: [
Application,
User,
Token,
ApplicationEntity,
UserEntity,
TokenEntity,
DeveloperEntity,
ClientEntity,
],
useUnifiedTopology: true,
logging: false,
});
this.applicationRepo = this.connection.getRepository(Application);
this.userRepo = this.connection.getRepository(User);
this.tokenRepo = this.connection.getRepository(Token);
}
}

View file

@ -1,21 +1,16 @@
import { nanoid } from 'nanoid';
import type { ObjectID } from 'typeorm';
import {
BaseEntity,
Column, Entity, ObjectIdColumn,
} from 'typeorm';
@Entity()
export default class Application extends BaseEntity {
@Entity({
name: 'applications',
})
export default class ApplicationEntity extends BaseEntity {
@ObjectIdColumn()
public _id!: ObjectID;
@Column()
public clientId!: string;
@Column()
public clientSecret!: string;
@Column()
public name!: string;
@ -29,15 +24,8 @@ export default class Application extends BaseEntity {
public verified!: boolean;
@Column()
public redirectUris!: string[];
@Column()
public ownerGitHubLogin!: string;
public developerId!: ObjectID;
@Column()
public homepage!: string | null;
public static generateClientId(): string {
return nanoid(12);
}
}

View file

@ -0,0 +1,36 @@
import { nanoid } from 'nanoid';
import type { ObjectID } from 'typeorm';
import {
BaseEntity, Column, Entity, ObjectIdColumn,
} from 'typeorm';
@Entity({
name: 'clients',
})
export default class ClientEntity extends BaseEntity {
@ObjectIdColumn()
public _id!: ObjectID;
@Column()
public clientId!: string;
@Column()
public clientSecret!: string;
@Column()
public name!: string;
@Column()
public redirectUris!: string[];
@Column()
public applicationId!: ObjectID;
public static generateClientId(): string {
return nanoid(12);
}
public static generateClientSecret(): string {
return nanoid(32);
}
}

View file

@ -0,0 +1,18 @@
import type { ObjectID } from 'typeorm';
import {
BaseEntity, Column, Entity, ObjectIdColumn,
} from 'typeorm';
@Entity({
name: 'developers',
})
export default class DeveloperEntity extends BaseEntity {
@ObjectIdColumn()
public _id!: ObjectID;
@Column()
public gitHubLogin!: string;
@Column()
public gitHubId!: string;
}

View file

@ -4,8 +4,10 @@ import {
BaseEntity, Column, Entity, ObjectIdColumn,
} from 'typeorm';
@Entity()
export default class Token extends BaseEntity {
@Entity({
name: 'tokens',
})
export default class TokenEntity extends BaseEntity {
@ObjectIdColumn()
public _id!: ObjectID;

View file

@ -3,8 +3,10 @@ import {
BaseEntity, Column, Entity, ObjectIdColumn,
} from 'typeorm';
@Entity()
export default class User extends BaseEntity {
@Entity({
name: 'users',
})
export default class UserEntity extends BaseEntity {
@ObjectIdColumn()
public _id!: ObjectID;

View file

@ -5,6 +5,7 @@ export const getUserQuery = gql`query GetUser($login: String!) {
login
name
url
avatarUrl
}
}
`;
@ -13,6 +14,7 @@ export interface User {
login: string;
name: string | null;
url: string;
avatarUrl: string;
}
export interface GetUserQueryResult {

View file

@ -0,0 +1,18 @@
import gql from 'graphql-tag';
export const getViewerQuery = gql`query GetViewer {
viewer {
login
id
}
}
`;
export interface Viewer {
login: string;
id: string;
}
export interface GetViewerQueryResult {
viewer: Viewer
}

View file

@ -2,13 +2,23 @@ import { GraphQLClient } from 'graphql-request';
import { requireEnv } from '../../utils';
import type { GetUserQueryResult, User } from './queries/get-user';
import { getUserQuery } from './queries/get-user';
import type { GetViewerQueryResult, Viewer } from './queries/get-viewer';
import { getViewerQuery } from './queries/get-viewer';
const client = new GraphQLClient('https://api.github.com/graphql');
client.setHeader('Authorization', `bearer ${requireEnv('GITHUB_API_TOKEN')}`);
export async function getUser(login: string): Promise<User> {
const { user } = await client.request<GetUserQueryResult>(getUserQuery, {
login,
}, {
Authorization: `bearer ${requireEnv('GITHUB_API_TOKEN')}`,
});
return user;
}
export async function getViewer(accessToken: string, tokenType: string): Promise<Viewer> {
const { viewer } = await client.request<GetViewerQueryResult>(getViewerQuery, {}, {
Authorization: `${tokenType} ${accessToken}`,
});
return viewer;
}

View file

@ -2,7 +2,7 @@ import _ from 'lodash';
import { nanoid } from 'nanoid';
import urlJoin from 'url-join';
import { scopes, websitePrefix } from '../../constants';
import database from '../../database/database';
import ClientEntity from '../../database/entities/client';
import { ParamError, ScopeError } from '../../errors';
import type { MyFastifyInstance, StudentsMode } from '../../types';
@ -42,16 +42,16 @@ export default function registerAuthorize(server: MyFastifyInstance): void {
return;
}
const application = await database.applicationRepo.findOne({
const client = await ClientEntity.findOne({
where: {
clientId: request.query.client_id,
},
});
if (application === undefined) {
if (client === undefined) {
await reply.redirect(urlJoin(websitePrefix, '/prompt-error?code=unknown_application'));
return;
}
if (!application.redirectUris.includes(request.query.redirect_uri)) {
if (!client.redirectUris.includes(request.query.redirect_uri)) {
await reply.redirect(urlJoin(websitePrefix, '/prompt-error?code=unknown_redirect_uri'));
return;
}
@ -88,7 +88,7 @@ export default function registerAuthorize(server: MyFastifyInstance): void {
const sessionData = getSessionData(request.session);
sessionData.authPrompts.set(promptId, {
clientId: request.query.client_id,
clientId: client.clientId,
redirectUri: request.query.redirect_uri,
scopes: requestedScopes,
state: request.query.state,

View file

@ -1,7 +1,7 @@
import type { FastifyReply } from 'fastify';
import { getCode, invalidateCode } from '../../codes';
import database from '../../database/database';
import Token from '../../database/entities/token';
import ClientEntity from '../../database/entities/client';
import TokenEntity from '../../database/entities/token';
import { ParamError } from '../../errors';
import type { CodeInfo, MyFastifyInstance, TokenContent } from '../../types';
@ -67,13 +67,13 @@ export default function registerToken(server: MyFastifyInstance): void {
return;
}
const application = await database.applicationRepo.findOne({
const client = await ClientEntity.findOne({
where: {
clientId: request.body.client_id,
},
});
if (!application) {
await sendCustomError(reply, 'invalid_client', 'Application not found', 401);
if (!client) {
await sendCustomError(reply, 'invalid_client', 'Client id not found', 401);
return;
}
@ -96,15 +96,15 @@ export default function registerToken(server: MyFastifyInstance): void {
}
} else {
validateParam('client_secret', request.body.client_secret);
if (application.clientSecret !== request.body.client_secret) {
if (client.clientSecret !== request.body.client_secret) {
await sendCustomError(reply, 'invalid_client', 'Invalid client secret', 401);
return;
}
}
const tokenId = Token.generateTokenId();
const tokenId = TokenEntity.generateTokenId();
const token = new Token();
const token = new TokenEntity();
token.tokenId = tokenId;
token.creationDate = new Date();
token.clientId = codeInfo.clientId;
@ -117,7 +117,7 @@ export default function registerToken(server: MyFastifyInstance): void {
token.encryptedSDK = codeInfo.encryptedSDK;
token.publicKey = codeInfo.publicKey;
await database.tokenRepo.save(token);
await token.save();
const content: TokenContent = {
tk: tokenKey,

View file

@ -0,0 +1,91 @@
import got from 'got';
import DeveloperEntity from '../../../database/entities/developer';
import { ParamError } from '../../../errors';
import { getViewer } from '../../../graphql/github/sdk';
import type SessionData from '../../../session-data';
import type { GitHubAuthorization, MyFastifyInstance } from '../../../types';
import {
getSessionData, isObject, requireEnv, validateOptionalParam, validateParam,
} from '../../../utils';
function getAuthorization(sessionData: SessionData, state?: string): GitHubAuthorization | null {
if (!state) return null;
return sessionData.gitHubAuthorizations.get(state) ?? null;
}
export default function registerGitHubCallback(server: MyFastifyInstance): void {
server.get('/developer/github-callback', async (
request,
reply,
) => {
const sessionData = getSessionData(request.session);
if (!isObject(request.query)) {
throw server.httpErrors.badRequest('Request query is not an object');
}
try {
validateOptionalParam('error', request.query.error);
validateOptionalParam('state', request.query.state);
} catch (error) {
server.log.error(error);
if (error instanceof ParamError) {
throw server.httpErrors.badRequest(error.message);
}
throw server.httpErrors.internalServerError();
}
const authorization = getAuthorization(sessionData, request.query.state);
if (request.query.error) {
if (authorization && request.query.state) sessionData.gitHubAuthorizations.delete(request.query.state);
if (request.query.error === 'access_denied') {
await reply.redirect(authorization?.returnTo ?? '/');
}
throw server.httpErrors.internalServerError(`Got error response: "${request.query.error}"`);
}
if (!request.query.state) throw server.httpErrors.badRequest('Missing state param');
if (!authorization) throw server.httpErrors.badRequest('Authorization not found');
try {
validateParam('code', request.query.code);
} catch (error) {
server.log.error(error);
if (error instanceof ParamError) {
throw server.httpErrors.badRequest(error.message);
}
throw server.httpErrors.internalServerError();
}
try {
const response = await got.post<{
token_type: string;
access_token: string;
scope: string;
}>('https://github.com/login/oauth/access_token', {
searchParams: {
client_id: requireEnv('GITHUB_CLIENT_ID'),
client_secret: requireEnv('GITHUB_CLIENT_SECRET'),
code: request.query.code,
},
responseType: 'json',
});
const viewer = await getViewer(response.body.access_token, response.body.token_type);
console.log(viewer);
let developer = await DeveloperEntity.findOne({
where: {
gitHubId: viewer.id,
},
});
if (!developer) {
developer = new DeveloperEntity();
developer.gitHubId = viewer.id;
}
developer.gitHubLogin = viewer.login;
await developer.save();
sessionData.loginState = {
developerId: developer._id,
};
sessionData.gitHubAuthorizations.delete(request.query.state);
await reply.redirect(authorization.returnTo);
} catch (error) {
server.log.error(error);
throw server.httpErrors.internalServerError();
}
});
}

View file

@ -0,0 +1,41 @@
import { URL } from 'url';
import { nanoid } from 'nanoid';
import { ParamError } from '../../../errors';
import type { MyFastifyInstance } from '../../../types';
import {
getSessionData, isObject, requireEnv, validateParam,
} from '../../../utils';
export default function registerGitHubSignIn(server: MyFastifyInstance): void {
server.get('/developer/sign-in/github', async (
request,
reply,
) => {
if (!isObject(request.query)) {
throw server.httpErrors.badRequest('Request query is not an object');
}
try {
validateParam('return_to', request.query.return_to);
} catch (error) {
server.log.error(error);
if (error instanceof ParamError) {
throw server.httpErrors.badRequest(error.message);
}
throw server.httpErrors.internalServerError();
}
const sessionData = getSessionData(request.session);
let state: string;
do {
state = nanoid();
} while (sessionData.gitHubAuthorizations.has(state));
const authorizeUrl = new URL('https://github.com/login/oauth/authorize');
authorizeUrl.searchParams.set('client_id', requireEnv('GITHUB_CLIENT_ID'));
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('redirect_uri', requireEnv('GITHUB_REDIRECT_URL'));
authorizeUrl.searchParams.set('state', state);
sessionData.gitHubAuthorizations.set(state, {
returnTo: request.query.return_to,
});
await reply.redirect(authorizeUrl.toString());
});
}

View file

@ -0,0 +1,13 @@
import type { MyFastifyInstance } from '../../../types';
import { getSessionData } from '../../../utils';
export default function registerSignOut(server: MyFastifyInstance): void {
server.get('/developer/sign-out', async (
request,
reply,
) => {
const sessionData = getSessionData(request.session);
sessionData.loginState = null;
await reply.redirect('/developer');
});
}

View file

@ -31,3 +31,11 @@ export class InvalidSymbolError extends ApolloError {
super('Invalid symbol', 'INVALID_SYMBOL');
}
}
export class ApplicationNotFoundError extends ApolloError {
public name = 'ApplicationNotFoundError';
public constructor() {
super('Application not found', 'APPLICATION_NOT_FOUND_ERROR');
}
}

View file

@ -4,10 +4,15 @@ import type { ApolloContext, MyFastifyInstance } from '../../types';
import { getSessionData } from '../../utils';
import registerAllow from './allow';
import registerDeny from './deny';
import registerGitHubCallback from './developer/github-callback';
import registerGitHubSignIn from './developer/github-sign-in';
import registerSignOut from './developer/sign-out';
import CreateUserResolver from './resolvers/authenticate-prompt/create-user-resolver';
import LoginResolver from './resolvers/authenticate-prompt/login-resolver';
import PromptInfoResolver from './resolvers/authenticate-prompt/prompt-info-resolver';
import SetSymbolResolver from './resolvers/authenticate-prompt/set-symbol-resolver';
import ApplicationResolver from './resolvers/developer/application';
import LoginStateResolver from './resolvers/developer/get-login-state';
import type { WebsiteAPIContext } from './types';
export default async function registerWebsiteApi(server: MyFastifyInstance): Promise<void> {
@ -18,6 +23,8 @@ export default async function registerWebsiteApi(server: MyFastifyInstance): Pro
LoginResolver,
SetSymbolResolver,
CreateUserResolver,
LoginStateResolver,
ApplicationResolver,
],
});
const apolloServer = new ApolloServer({
@ -35,4 +42,8 @@ export default async function registerWebsiteApi(server: MyFastifyInstance): Pro
registerDeny(server);
registerAllow(server);
registerGitHubSignIn(server);
registerGitHubCallback(server);
registerSignOut(server);
}

View file

@ -0,0 +1,38 @@
import { Field, ObjectType } from 'type-graphql';
import type ApplicationEntity from '../../../database/entities/application';
@ObjectType()
export default class Application {
@Field(() => String)
public id!: string;
@Field(() => String)
public name!: string;
@Field(() => String, {
nullable: true,
})
public iconUrl!: string | null;
@Field(() => String)
public iconColor!: string;
@Field(() => String, {
nullable: true,
})
public homepage!: string | null;
@Field(() => Boolean)
public verified!: boolean;
public static fromEntity(entity: ApplicationEntity): Application {
return {
id: entity._id.toHexString(),
iconColor: entity.iconColor,
iconUrl: entity.iconUrl,
name: entity.name,
homepage: entity.homepage,
verified: entity.verified,
};
}
}

View file

@ -0,0 +1,15 @@
import { Field, ObjectType } from 'type-graphql';
@ObjectType()
export default class LoginState {
@Field(() => String, {
nullable: true,
})
public name!: string | null;
@Field(() => String)
public login!: string;
@Field(() => String)
public avatarUrl!: string;
}

View file

@ -18,7 +18,7 @@ export default class PromptInfoApplication {
public verified!: boolean;
@Field(() => GitHubUser)
public owner!: GitHubUser;
public developer!: GitHubUser;
@Field(() => String, {
nullable: true,

View file

@ -10,9 +10,6 @@ export default class PromptInfo {
@Field(() => [String])
public scopes!: string[];
@Field(() => String)
public clientId!: string;
@Field(() => StudentsMode)
public studentsMode!: StudentsMode;

View file

@ -3,8 +3,7 @@ import { UserInputError } from 'apollo-server-fastify';
import {
Arg, Ctx, Mutation, Resolver,
} from 'type-graphql';
import database from '../../../../database/database';
import User from '../../../../database/entities/user';
import UserEntity from '../../../../database/entities/user';
import { UnknownPromptError } from '../../errors';
import CreateUserResult from '../../models/create-user-result';
import type { WebsiteAPIContext } from '../../types';
@ -23,7 +22,7 @@ export default class CreateUserResolver {
if (!prompt.loginInfo) throw new UserInputError('Login data not provided');
if (!prompt.loginInfo.symbolInfo) throw new UserInputError('Symbol not provided');
const existingUser = await database.userRepo.findOne({
const existingUser = await UserEntity.findOne({
where: {
host: prompt.loginInfo.host,
symbol: prompt.loginInfo.symbolInfo.symbol,
@ -33,13 +32,13 @@ export default class CreateUserResolver {
},
});
if (existingUser !== undefined) throw new UserInputError('User already exists');
const user = new User();
const user = new UserEntity();
user.host = prompt.loginInfo.host;
user.symbol = prompt.loginInfo.symbolInfo.symbol;
user.username = prompt.loginInfo.username;
user.loginIds = prompt.loginInfo.symbolInfo.loginIds;
user.email = email;
await database.userRepo.save(user);
await user.save();
prompt.loginInfo.symbolInfo.userId = user._id;
return {
success: true,

View file

@ -1,47 +1,47 @@
/* eslint-disable class-methods-use-this */
import type { ResolverInterface } from 'type-graphql';
import { ApolloError } from 'apollo-server-fastify';
import {
Arg, Ctx, FieldResolver, Query, Resolver, Root,
Arg, Ctx, Query, Resolver,
} from 'type-graphql';
import database from '../../../../database/database';
import ApplicationEntity from '../../../../database/entities/application';
import ClientEntity from '../../../../database/entities/client';
import DeveloperEntity from '../../../../database/entities/developer';
import { getUser } from '../../../../graphql/github/sdk';
import { UnknownPromptError } from '../../errors';
import PromptInfo from '../../models/prompt-info';
import type PromptInfoApplication from '../../models/prompt-info-application';
import type { WebsiteAPIContext } from '../../types';
@Resolver(PromptInfo)
export default class PromptInfoResolver implements ResolverInterface<PromptInfo> {
export default class PromptInfoResolver {
@Query(() => PromptInfo)
public promptInfo(
public async promptInfo(
@Arg('promptId') promptId: string,
@Ctx() { sessionData }: WebsiteAPIContext,
): Partial<PromptInfo> {
): Promise<Partial<PromptInfo>> {
const prompt = sessionData.authPrompts.get(promptId);
if (!prompt) throw new UnknownPromptError();
return {
id: promptId,
clientId: prompt.clientId,
scopes: prompt.scopes,
studentsMode: prompt.studentsMode,
};
}
@FieldResolver()
public async application(@Root() prompt: PromptInfo): Promise<PromptInfoApplication> {
const application = await database.applicationRepo.findOne({
const client = await ClientEntity.findOne({
where: {
clientId: prompt.clientId,
},
});
if (!application) throw new Error('Prompt data not found');
if (!client) throw new ApolloError('Client not found');
const application = await ApplicationEntity.findOne(client.applicationId);
if (!application) throw new ApolloError('Application not found');
const developer = await DeveloperEntity.findOne(application.developerId);
if (!developer) throw new ApolloError('Developer not found');
return {
id: promptId,
scopes: prompt.scopes,
studentsMode: prompt.studentsMode,
application: {
name: application.name,
iconUrl: application.iconUrl,
iconColor: application.iconColor,
verified: application.verified,
homepage: application.homepage,
owner: await getUser(application.ownerGitHubLogin),
developer: await getUser(developer.gitHubLogin),
},
};
}
}

View file

@ -7,8 +7,7 @@ import _ from 'lodash';
import {
Arg, Ctx, Mutation, Resolver,
} from 'type-graphql';
import database from '../../../../database/database';
import User from '../../../../database/entities/user';
import UserEntity from '../../../../database/entities/user';
import { decryptSymmetrical, decryptWithPrivateKey, encryptSymmetrical } from '../../../../utils';
import { InvalidSymbolError, UnknownPromptError } from '../../errors';
import type LoginStudent from '../../models/login-student';
@ -59,12 +58,12 @@ export default class SetSymbolResolver {
}));
const reportingUnits = await client.getReportingUnits();
const loginIds = reportingUnits.map((unit) => User.getLoginId(unit.senderId, unit.unitId));
const loginIds = reportingUnits.map((unit) => UserEntity.getLoginId(unit.senderId, unit.unitId));
const encryptedClient = encryptSymmetrical(JSON.stringify(client.serialize()), tokenKey);
const encryptedDiaries = encryptSymmetrical(JSON.stringify(diaryList.map(({ serialized }) => serialized)), tokenKey);
const user = await database.userRepo.findOne({
const user = await UserEntity.findOne({
where: {
host: prompt.loginInfo.host,
symbol,

View file

@ -0,0 +1,97 @@
/* eslint-disable class-methods-use-this */
import { URL } from 'url';
import { UserInputError } from 'apollo-server-fastify';
import {
Arg, Ctx, Mutation, Query, Resolver, UnauthorizedError,
} from 'type-graphql';
import ApplicationEntity from '../../../../database/entities/application';
import { ApplicationNotFoundError } from '../../errors';
import Application from '../../models/application';
import type { WebsiteAPIContext } from '../../types';
@Resolver()
export default class ApplicationResolver {
@Mutation(() => Application)
public async createApplication(
@Arg('name') name: string,
@Ctx() { sessionData }: WebsiteAPIContext,
): Promise<Application> {
if (!sessionData.loginState) throw new UnauthorizedError();
if (name !== name.trim()) throw new UserInputError('Name should be trimmed');
if (name.trim().length < 3) throw new UserInputError('Name is too short');
if (name.trim().length > 32) throw new UserInputError('Name is too long');
const application = new ApplicationEntity();
application.developerId = sessionData.loginState.developerId;
application.homepage = null;
application.verified = false;
application.iconUrl = null;
application.iconColor = '#444444';
application.name = name;
await application.save();
return Application.fromEntity(application);
}
@Query(() => [Application])
public async applications(
@Ctx() { sessionData }: WebsiteAPIContext,
): Promise<Application[]> {
if (!sessionData.loginState) throw new UnauthorizedError();
const applications = await ApplicationEntity.find({
where: {
developerId: sessionData.loginState.developerId,
},
});
return applications.map((app) => Application.fromEntity(app));
}
@Query(() => Application, {
nullable: true,
})
public async application(
@Arg('id') id: string,
@Ctx() { sessionData }: WebsiteAPIContext,
): Promise<Application | null> {
if (!sessionData.loginState) throw new UnauthorizedError();
const application = await ApplicationEntity.findOne(id);
if (!application) return null;
if (!application.developerId.equals(sessionData.loginState.developerId)) throw new UnauthorizedError();
return Application.fromEntity(application);
}
@Mutation(() => Application)
public async modifyApplication(
@Arg('id') id: string,
@Arg('name') name: string,
@Arg('homepage', () => String, {
nullable: true,
}) homepage: string | null,
@Ctx() { sessionData }: WebsiteAPIContext,
): Promise<Application> {
if (!sessionData.loginState) throw new UnauthorizedError();
if (name !== name.trim()) throw new UserInputError('Name should be trimmed');
if (name.trim().length < 3) throw new UserInputError('Name is too short');
if (name.trim().length > 32) throw new UserInputError('Name is too long');
if (homepage) {
if (homepage.trim() === '') throw new UserInputError('Homepage should not be an empty string. Use null instead');
let url: URL;
try {
url = new URL(homepage.trim());
} catch {
throw new UserInputError('Homepage URL is invalid');
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new UserInputError('Invalid homepage URL protocol');
}
const application = await ApplicationEntity.findOne(id);
if (!application) throw new ApplicationNotFoundError();
if (!application.developerId.equals(sessionData.loginState.developerId)) throw new UnauthorizedError();
if (application.homepage === homepage && application.name === name) return Application.fromEntity(application);
application.name = name;
application.homepage = homepage;
application.verified = false;
await application.save();
return Application.fromEntity(application);
}
}

View file

@ -0,0 +1,31 @@
/* eslint-disable class-methods-use-this */
import { Ctx, Query, Resolver } from 'type-graphql';
import DeveloperEntity from '../../../../database/entities/developer';
import { getUser } from '../../../../graphql/github/sdk';
import LoginState from '../../models/login-state';
import type { WebsiteAPIContext } from '../../types';
@Resolver(LoginState)
export default class LoginStateResolver {
@Query(() => LoginState, {
nullable: true,
})
public async loginState(
@Ctx() { sessionData }: WebsiteAPIContext,
): Promise<LoginState | null> {
if (!sessionData.loginState) return null;
const developer = await DeveloperEntity.findOne(sessionData.loginState.developerId);
if (!developer) {
console.error('Developer not found');
// eslint-disable-next-line no-param-reassign
sessionData.loginState = null;
return null;
}
const gitHubUser = await getUser(developer.gitHubLogin);
return {
login: gitHubUser.login,
name: gitHubUser.name,
avatarUrl: gitHubUser.avatarUrl,
};
}
}

View file

@ -1,5 +1,9 @@
import type { AuthPrompt } from './types';
import type { AuthPrompt, DeveloperLoginState, GitHubAuthorization } from './types';
export default class SessionData {
public authPrompts = new Map<string, AuthPrompt>();
public gitHubAuthorizations = new Map<string, GitHubAuthorization>();
public loginState: DeveloperLoginState | null = null;
}

View file

@ -95,3 +95,11 @@ export interface CodeContent {
export interface TokenContent {
tk: string;
}
export interface GitHubAuthorization {
returnTo: string;
}
export interface DeveloperLoginState {
developerId: ObjectID,
}

View file

@ -1 +0,0 @@
VUE_APP_CAPTCHA_SITE_KEY=6LfAxzUaAAAAANF5VLy39hbgx5K6WTQTa2YDdhmC

View file

@ -15,23 +15,23 @@ module.exports = {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'import/prefer-default-export': ['warn'],
"prefer-destructuring": ["error", {
"array": false,
"object": true
}]
'prefer-destructuring': ['error', {
array: false,
object: true,
}],
},
overrides: [
{
files: './src/graphql/generated.ts',
rules: {
'max-len': ['off']
'max-len': ['off'],
},
},
{
files: '**/*.vue',
rules: {
'class-methods-use-this': ['off']
}
}
'class-methods-use-this': ['off'],
},
},
],
};

View file

@ -1,9 +1,7 @@
schema: http://localhost:3000/api/website/graphql
documents:
- src/graphql/queries/get-prompt-info.ts
- src/graphql/mutations/login.ts
- src/graphql/mutations/set-symbol.ts
- src/graphql/mutations/create-user.ts
- src/graphql/queries/*.ts
- src/graphql/mutations/*.ts
generates:
./src/graphql/generated.ts:
plugins:

View file

@ -3870,12 +3870,55 @@
}
},
"@vue/cli-plugin-router": {
"version": "4.5.10",
"resolved": "https://registry.npmjs.org/@vue/cli-plugin-router/-/cli-plugin-router-4.5.10.tgz",
"integrity": "sha512-roiZTx2W59kTRaqNzHEnjnakP89MS+pVf3zWBlwsNXZpQuvqwFvoNfH/nBSJjqGRgZTRtCUe6vGgVPUEFYi/cg==",
"version": "4.5.11",
"resolved": "https://registry.npmjs.org/@vue/cli-plugin-router/-/cli-plugin-router-4.5.11.tgz",
"integrity": "sha512-09tzw3faOs48IUPwLutYaNC7eoyyL140fKruTwdFdXuBLDdSQVida57Brx0zj2UKXc5qF8hk4GoGrOshN0KfNg==",
"dev": true,
"requires": {
"@vue/cli-shared-utils": "^4.5.10"
"@vue/cli-shared-utils": "^4.5.11"
},
"dependencies": {
"@vue/cli-shared-utils": {
"version": "4.5.11",
"resolved": "https://registry.npmjs.org/@vue/cli-shared-utils/-/cli-shared-utils-4.5.11.tgz",
"integrity": "sha512-+aaQ+ThQG3+WMexfSWNl0y6f43edqVqRNbguE53F3TIH81I7saS5S750ayqXhZs2r6STJJyqorQnKtAWfHo29A==",
"dev": true,
"requires": {
"@hapi/joi": "^15.0.1",
"chalk": "^2.4.2",
"execa": "^1.0.0",
"launch-editor": "^2.2.1",
"lru-cache": "^5.1.1",
"node-ipc": "^9.1.1",
"open": "^6.3.0",
"ora": "^3.4.0",
"read-pkg": "^5.1.1",
"request": "^2.88.2",
"semver": "^6.1.0",
"strip-ansi": "^6.0.0"
}
},
"lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
"integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
"dev": true,
"requires": {
"yallist": "^3.0.2"
}
},
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
"integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
"dev": true
},
"yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true
}
}
},
"@vue/cli-plugin-typescript": {
@ -16095,6 +16138,11 @@
"resolved": "https://registry.npmjs.org/vue-recaptcha/-/vue-recaptcha-1.3.0.tgz",
"integrity": "sha512-9Qf1niyHq4QbEUhsvdUkS8BoOyhYwpp8v+imUSj67ffDo9RQ6h8Ekq8EGnw/GKViXCwWalp7EEY/n/fOtU0FyA=="
},
"vue-router": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.5.1.tgz",
"integrity": "sha512-RRQNLT8Mzr8z7eL4p7BtKvRaTSGdCbTy2+Mm5HTJvLGYSSeG9gDzNasJPP/yOYKLy+/cLG/ftrqq5fvkFwBJEw=="
},
"vue-style-loader": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.2.tgz",

View file

@ -18,6 +18,7 @@
"vue-class-component": "^7.2.3",
"vue-property-decorator": "^9.1.2",
"vue-recaptcha": "^1.3.0",
"vue-router": "^3.2.0",
"vuetify": "^2.2.11"
},
"devDependencies": {
@ -29,6 +30,7 @@
"@typescript-eslint/parser": "^2.33.0",
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "^4.5.11",
"@vue/cli-plugin-typescript": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/eslint-config-airbnb": "^5.0.2",

View file

@ -54,7 +54,7 @@
</v-list-item-content>
</v-list-item>
<v-list-item
:href="promptInfo.application.owner.url"
:href="promptInfo.application.developer.url"
target="_blank"
rel="noopener"
>
@ -65,14 +65,14 @@
<v-list-item-subtitle class="text-overline">
Twórca
</v-list-item-subtitle>
<v-list-item-title v-if="promptInfo.application.owner.name">
{{ promptInfo.application.owner.name }}
<v-list-item-title v-if="promptInfo.application.developer.name">
{{ promptInfo.application.developer.name }}
<span class="text--secondary">
({{ promptInfo.application.owner.login }})
({{ promptInfo.application.developer.login }})
</span>
</v-list-item-title>
<v-list-item-title v-else>
{{ promptInfo.application.owner.login }}
{{ promptInfo.application.developer.login }}
</v-list-item-title>
</v-list-item-content>
</v-list-item>

View file

@ -50,7 +50,7 @@ import {
import { InputValidationRules } from 'vuetify';
import IsEmail from 'isemail';
import { PromptInfo, VForm } from '@/types';
import { sdk } from '@/pages/authenticate-prompt/sdk';
import { sdk } from '@/graphql/sdk';
@Component({
name: 'EmailWindow',

View file

@ -54,7 +54,7 @@ import {
Component, Prop, Ref, Vue,
} from 'vue-property-decorator';
import { PromptInfo, VForm } from '@/types';
import { hasErrorCode, sdk } from '@/pages/authenticate-prompt/sdk';
import { hasErrorCode, sdk } from '@/graphql/sdk';
import { InputValidationRules } from 'vuetify';
import VueRecaptcha from 'vue-recaptcha';
import { requireEnv } from '@/utils';

View file

@ -74,7 +74,7 @@ import {
Component, Prop, Ref, Vue,
} from 'vue-property-decorator';
import { PromptInfo, VForm } from '@/types';
import { hasErrorCode, sdk } from '@/pages/authenticate-prompt/sdk';
import { hasErrorCode, sdk } from '@/graphql/sdk';
// This is a JS Symbol, not a diary symbol
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol)

View file

@ -0,0 +1,93 @@
<template>
<v-dialog v-model="value" max-width="450" :persistent="loading">
<template #activator="{ on }">
<slot :on="on" name="activator" />
</template>
<v-card>
<v-form @submit.prevent="submit">
<v-card-title>
Create new application
</v-card-title>
<v-card-text class="pt-4">
<v-text-field
label="Application name"
hint="Users will see this name"
persistent-hint
autofocus
outlined
counter="32"
:counter-value="(v) => v.trim().length"
v-model="name"
/>
<v-alert type="error" class="mb-0 mt-2" :value="error">
An error occured
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="primary"
text
@click="value = false"
>
Cancel
</v-btn>
<v-btn
color="primary"
:disabled="!valid"
:loading="loading"
type="submit"
>
Create
</v-btn>
</v-card-actions>
</v-form>
</v-card>
</v-dialog>
</template>
<script lang="ts">
import { Component, Vue, Watch } from 'vue-property-decorator';
import { sdk } from '@/graphql/sdk';
@Component({
name: 'NewAppDialog',
})
export default class NewAppDialog extends Vue {
value = false;
name = '';
loading = false;
error = false;
@Watch('value')
valueChanged(value: boolean) {
if (value) return;
this.name = '';
this.error = false;
}
get valid() {
return this.name.trim().length >= 3 && this.name.trim().length <= 32;
}
async submit() {
if (!this.valid || this.loading) return;
this.loading = true;
this.error = false;
try {
const result = await sdk.CreateApplication({
name: this.name.trim(),
});
await this.$router.push(`/apps/${result.createApplication.id}`);
this.value = false;
} catch (error) {
console.error(error);
this.error = true;
}
this.loading = false;
}
}
</script>t

View file

@ -5,7 +5,7 @@
<span class="primary--text">Wulkanowy</span> Bridge
</div>
</div>
<v-main class="px-4">
<v-main class="px-4 pt-4">
<v-sheet max-width="500" class="mx-auto mt-16" color="transparent">
<slot />
</v-sheet>

View file

@ -18,17 +18,23 @@ export type Scalars = {
export type Query = {
__typename?: 'Query';
promptInfo: PromptInfo;
applications: Array<Application>;
application: Maybe<Application>;
loginState: Maybe<LoginState>;
};
export type QueryPromptInfoArgs = {
promptId: Scalars['String'];
};
export type QueryApplicationArgs = {
id: Scalars['String'];
};
export type PromptInfo = {
__typename?: 'PromptInfo';
id: Scalars['String'];
scopes: Array<Scalars['String']>;
clientId: Scalars['String'];
studentsMode: StudentsMode;
application: PromptInfoApplication;
};
@ -45,7 +51,7 @@ export type PromptInfoApplication = {
iconUrl: Maybe<Scalars['String']>;
iconColor: Scalars['String'];
verified: Scalars['Boolean'];
owner: GitHubUser;
developer: GitHubUser;
homepage: Maybe<Scalars['String']>;
};
@ -56,11 +62,30 @@ export type GitHubUser = {
url: Scalars['String'];
};
export type Application = {
__typename?: 'Application';
id: Scalars['String'];
name: Scalars['String'];
iconUrl: Maybe<Scalars['String']>;
iconColor: Scalars['String'];
homepage: Maybe<Scalars['String']>;
verified: Scalars['Boolean'];
};
export type LoginState = {
__typename?: 'LoginState';
name: Maybe<Scalars['String']>;
login: Scalars['String'];
avatarUrl: Scalars['String'];
};
export type Mutation = {
__typename?: 'Mutation';
createUser: CreateUserResult;
login: LoginResult;
setSymbol: SetSymbolResult;
createApplication: Application;
modifyApplication: Application;
};
export type MutationCreateUserArgs = {
@ -81,6 +106,16 @@ export type MutationSetSymbolArgs = {
promptId: Scalars['String'];
};
export type MutationCreateApplicationArgs = {
name: Scalars['String'];
};
export type MutationModifyApplicationArgs = {
homepage: Maybe<Scalars['String']>;
name: Scalars['String'];
id: Scalars['String'];
};
export type CreateUserResult = {
__typename?: 'CreateUserResult';
success: Scalars['Boolean'];
@ -103,6 +138,18 @@ export type LoginStudent = {
name: Scalars['String'];
};
export type CreateApplicationMutationVariables = Exact<{
name: Scalars['String'];
}>;
export type CreateApplicationMutation = (
{ __typename?: 'Mutation' }
& { createApplication: (
{ __typename?: 'Application' }
& Pick<Application, 'id'>
); }
);
export type CreateUserMutationVariables = Exact<{
promptId: Scalars['String'];
email: Scalars['String'];
@ -132,6 +179,20 @@ export type LoginMutation = (
); }
);
export type ModifyApplicationMutationVariables = Exact<{
id: Scalars['String'];
name: Scalars['String'];
homepage: Maybe<Scalars['String']>;
}>;
export type ModifyApplicationMutation = (
{ __typename?: 'Mutation' }
& { modifyApplication: (
{ __typename?: 'Application' }
& Pick<Application, 'name' | 'iconUrl' | 'iconColor' | 'homepage' | 'verified'>
); }
);
export type SetSymbolMutationVariables = Exact<{
promptId: Scalars['String'];
symbol: Scalars['String'];
@ -149,6 +210,38 @@ export type SetSymbolMutation = (
); }
);
export type GetApplicationQueryVariables = Exact<{
id: Scalars['String'];
}>;
export type GetApplicationQuery = (
{ __typename?: 'Query' }
& { application: Maybe<(
{ __typename?: 'Application' }
& Pick<Application, 'name' | 'iconUrl' | 'iconColor' | 'homepage' | 'verified'>
)>; }
);
export type GetApplicationsQueryVariables = Exact<{ [key: string]: never }>;
export type GetApplicationsQuery = (
{ __typename?: 'Query' }
& { applications: Array<(
{ __typename?: 'Application' }
& Pick<Application, 'id' | 'name' | 'iconUrl' | 'iconColor'>
)>; }
);
export type GetLoginStateQueryVariables = Exact<{ [key: string]: never }>;
export type GetLoginStateQuery = (
{ __typename?: 'Query' }
& { loginState: Maybe<(
{ __typename?: 'LoginState' }
& Pick<LoginState, 'name' | 'avatarUrl' | 'login'>
)>; }
);
export type GetPromptInfoQueryVariables = Exact<{
promptId: Scalars['String'];
}>;
@ -161,7 +254,7 @@ export type GetPromptInfoQuery = (
& { application: (
{ __typename?: 'PromptInfoApplication' }
& Pick<PromptInfoApplication, 'name' | 'iconUrl' | 'iconColor' | 'verified' | 'homepage'>
& { owner: (
& { developer: (
{ __typename?: 'GitHubUser' }
& Pick<GitHubUser, 'login' | 'name' | 'url'>
); }
@ -169,6 +262,13 @@ export type GetPromptInfoQuery = (
); }
);
export const CreateApplicationDocument = gql`
mutation CreateApplication($name: String!) {
createApplication(name: $name) {
id
}
}
`;
export const CreateUserDocument = gql`
mutation CreateUser($promptId: String!, $email: String!) {
createUser(promptId: $promptId, email: $email) {
@ -189,6 +289,17 @@ export const LoginDocument = gql`
}
}
`;
export const ModifyApplicationDocument = gql`
mutation ModifyApplication($id: String!, $name: String!, $homepage: String) {
modifyApplication(id: $id, name: $name, homepage: $homepage) {
name
iconUrl
iconColor
homepage
verified
}
}
`;
export const SetSymbolDocument = gql`
mutation SetSymbol($promptId: String!, $symbol: String!) {
setSymbol(promptId: $promptId, symbol: $symbol) {
@ -200,6 +311,36 @@ export const SetSymbolDocument = gql`
}
}
`;
export const GetApplicationDocument = gql`
query GetApplication($id: String!) {
application(id: $id) {
name
iconUrl
iconColor
homepage
verified
}
}
`;
export const GetApplicationsDocument = gql`
query GetApplications {
applications {
id
name
iconUrl
iconColor
}
}
`;
export const GetLoginStateDocument = gql`
query GetLoginState {
loginState {
name
avatarUrl
login
}
}
`;
export const GetPromptInfoDocument = gql`
query GetPromptInfo($promptId: String!) {
promptInfo(promptId: $promptId) {
@ -212,7 +353,7 @@ export const GetPromptInfoDocument = gql`
iconColor
verified
homepage
owner {
developer {
login
name
url
@ -227,15 +368,30 @@ export type SdkFunctionWrapper = <T>(action: () => Promise<T>) => Promise<T>;
const defaultWrapper: SdkFunctionWrapper = (sdkFunction) => sdkFunction();
export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = defaultWrapper) {
return {
CreateApplication(variables: CreateApplicationMutationVariables, requestHeaders?: Headers): Promise<CreateApplicationMutation> {
return withWrapper(() => client.request<CreateApplicationMutation>(print(CreateApplicationDocument), variables, requestHeaders));
},
CreateUser(variables: CreateUserMutationVariables, requestHeaders?: Headers): Promise<CreateUserMutation> {
return withWrapper(() => client.request<CreateUserMutation>(print(CreateUserDocument), variables, requestHeaders));
},
Login(variables: LoginMutationVariables, requestHeaders?: Headers): Promise<LoginMutation> {
return withWrapper(() => client.request<LoginMutation>(print(LoginDocument), variables, requestHeaders));
},
ModifyApplication(variables: ModifyApplicationMutationVariables, requestHeaders?: Headers): Promise<ModifyApplicationMutation> {
return withWrapper(() => client.request<ModifyApplicationMutation>(print(ModifyApplicationDocument), variables, requestHeaders));
},
SetSymbol(variables: SetSymbolMutationVariables, requestHeaders?: Headers): Promise<SetSymbolMutation> {
return withWrapper(() => client.request<SetSymbolMutation>(print(SetSymbolDocument), variables, requestHeaders));
},
GetApplication(variables: GetApplicationQueryVariables, requestHeaders?: Headers): Promise<GetApplicationQuery> {
return withWrapper(() => client.request<GetApplicationQuery>(print(GetApplicationDocument), variables, requestHeaders));
},
GetApplications(variables?: GetApplicationsQueryVariables, requestHeaders?: Headers): Promise<GetApplicationsQuery> {
return withWrapper(() => client.request<GetApplicationsQuery>(print(GetApplicationsDocument), variables, requestHeaders));
},
GetLoginState(variables?: GetLoginStateQueryVariables, requestHeaders?: Headers): Promise<GetLoginStateQuery> {
return withWrapper(() => client.request<GetLoginStateQuery>(print(GetLoginStateDocument), variables, requestHeaders));
},
GetPromptInfo(variables: GetPromptInfoQueryVariables, requestHeaders?: Headers): Promise<GetPromptInfoQuery> {
return withWrapper(() => client.request<GetPromptInfoQuery>(print(GetPromptInfoDocument), variables, requestHeaders));
},

View file

@ -0,0 +1,8 @@
import gql from 'graphql-tag';
export default gql`mutation CreateApplication($name: String!) {
createApplication(name: $name) {
id
}
}
`;

View file

@ -0,0 +1,12 @@
import gql from 'graphql-tag';
export default gql`mutation ModifyApplication($id: String!, $name: String!, $homepage: String) {
modifyApplication(id: $id, name: $name, homepage: $homepage) {
name
iconUrl
iconColor
homepage
verified
}
}
`;

View file

@ -0,0 +1,11 @@
import gql from 'graphql-tag';
export default gql`query GetApplication($id: String!) {
application(id: $id) {
name
iconUrl
iconColor
homepage
verified
}
}`;

View file

@ -0,0 +1,10 @@
import gql from 'graphql-tag';
export default gql`query GetApplications {
applications {
id
name
iconUrl
iconColor
}
}`;

View file

@ -0,0 +1,9 @@
import gql from 'graphql-tag';
export default gql`query GetLoginState {
loginState {
name
avatarUrl
login
}
}`;

View file

@ -11,7 +11,7 @@ export default gql`query GetPromptInfo($promptId: String!) {
iconColor
verified
homepage
owner {
developer {
login
name
url

View file

@ -0,0 +1,80 @@
<template>
<v-sheet
:width="circleSize"
:height="circleSize"
class="app-icon overflow-hidden"
outlined
>
<v-sheet
class="fill-height d-flex align-center justify-center"
:color="color"
>
<v-img
:src="url"
:width="imgSize"
:height="imgSize"
aspect-ratio="1"
contain
>
<template v-slot:placeholder>
<div class="fill-height d-flex align-center justify-center">
<v-icon :size="imgSize">
mdi-help
</v-icon>
</div>
</template>
</v-img>
</v-sheet>
</v-sheet>
</template>
<style lang="scss">
.app-icon {
border-radius: 50% !important;
}
</style>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
@Component({
name: 'AppIcon',
})
export default class AppIcon extends Vue {
@Prop({
type: String,
required: true,
})
color!: string;
@Prop({
type: String,
default: null,
})
url!: string | null
@Prop({
type: Boolean,
default: false,
})
large!: boolean
@Prop({
type: Boolean,
default: false,
})
small!: boolean
get circleSize() {
if (this.large) return 128;
if (this.small) return 64;
return 96;
}
get imgSize() {
if (this.large) return 80;
if (this.small) return 40;
return 60;
}
}
</script>

View file

@ -16,7 +16,7 @@
</div>
<template v-else>
<v-card outlined>
<div class="d-flex justify-center mn-16 avatar__wrapper">
<div class="d-flex justify-center app-icon-wrapper">
<v-badge
:color="promptInfo.application.verified ? 'green' : 'grey'"
offset-x="64"
@ -26,34 +26,13 @@
:value="step === 'overview'"
>
<transition name="scale">
<v-sheet
<app-icon
v-if="step === 'overview'"
width="128"
height="128"
class="avatar-sheet mx-4 overflow-hidden"
outlined
>
<v-sheet
class="fill-height d-flex align-center justify-center"
class="mx-4"
:color="promptInfo.application.iconColor"
>
<v-img
:src="promptInfo.application.iconUrl"
width="80"
height="80"
aspect-ratio="1"
contain
>
<template v-slot:placeholder>
<div class="fill-height d-flex align-center justify-center">
<v-icon :size="80">
mdi-help
</v-icon>
</div>
</template>
</v-img>
</v-sheet>
</v-sheet>
:url="promptInfo.application.iconUrl"
large
/>
</transition>
</v-badge>
</div>
@ -106,15 +85,11 @@
<style lang="scss">
.authenticate-prompt-app {
.avatar-sheet {
border-radius: 50% !important;
}
.fill-height {
height: 100%;
}
.avatar__wrapper {
.app-icon-wrapper {
position: absolute;
top: -64px;
width: 100%;
@ -135,15 +110,17 @@ import OverviewWindow from '@/compontents/authenticate-prompt-windows/overview-w
import { PromptInfo, Student } from '@/types';
import LoginWindow from '@/compontents/authenticate-prompt-windows/login-window.vue';
import StudentsWindow from '@/compontents/authenticate-prompt-windows/students-window.vue';
import { sdk } from '@/pages/authenticate-prompt/sdk';
import { sdk } from '@/graphql/sdk';
import DialogApp from '@/compontents/dialog-app.vue';
import EmailWindow from '@/compontents/authenticate-prompt-windows/email-window.vue';
import SymbolsWindow from '@/compontents/authenticate-prompt-windows/symbols-window.vue';
import IsEmail from 'isemail';
import AppIcon from '@/pages/app-icon.vue';
@Component({
name: 'AuthenticatePromptApp',
components: {
AppIcon,
SymbolsWindow,
EmailWindow,
LoginWindow,

View file

@ -0,0 +1,88 @@
<template>
<v-app class="developer-app">
<v-container class="d-flex align-center justify-center fill-height" v-if="loginStateLoading">
<v-progress-circular indeterminate color="primary" :size="96" />
</v-container>
<developer-signed-out v-else-if="loginState === null" />
<div v-else>
<v-app-bar app color="primary" dark>
<v-btn icon to="/" v-if="$route.name !== 'Home'">
<v-icon>mdi-arrow-left</v-icon>
</v-btn>
<v-app-bar-title>Developer</v-app-bar-title>
<v-spacer />
<v-menu offset-y nudge-bottom="12" min-width="350">
<template #activator="{ on }">
<v-btn icon v-on="on">
<v-avatar>
<v-img :src="loginState.avatarUrl" :width="48" />
</v-avatar>
</v-btn>
</template>
<v-card outlined>
<v-card-title v-if="loginState.name" class="d-block">
{{ loginState.name }}
<span class="text--secondary">
({{ loginState.login }})
</span>
</v-card-title>
<v-card-title v-else>
{{ loginState.login }}
</v-card-title>
<v-card-subtitle>Developer account</v-card-subtitle>
<v-divider />
<v-card-actions>
<v-btn
block
color="primary"
outlined
href="/api/website/developer/sign-out"
>
Sign out
</v-btn>
</v-card-actions>
</v-card>
</v-menu>
</v-app-bar>
<v-main>
<router-view :login-state="loginState" />
</v-main>
</div>
</v-app>
</template>
<style lang="scss">
.developer-app {
background-color: #f7f7f7 !important;
}
.v-card__text, .v-card__title {
word-break: normal;
}
.no-basis {
flex-basis: 0;
}
</style>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import DeveloperSignedOut from '@/pages/developer/views/signed-out.vue';
import { LoginState } from '@/graphql/generated';
import { sdk } from '@/graphql/sdk';
@Component({
name: 'DeveloperApp',
components: { DeveloperSignedOut },
})
export default class DeveloperApp extends Vue {
loginStateLoading = true;
loginState: LoginState | null = null;
async created() {
this.loginState = (await sdk.GetLoginState()).loginState;
this.loginStateLoading = false;
}
}
</script>

View file

@ -0,0 +1,12 @@
import Vue from 'vue';
import vuetify from '@/plugins/vuetify';
import DeveloperApp from './app.vue';
import router from './router';
Vue.config.productionTip = false;
new Vue({
vuetify,
router,
render: (h) => h(DeveloperApp),
}).$mount('#app');

View file

@ -0,0 +1,29 @@
import Vue from 'vue';
import VueRouter, { RouteConfig } from 'vue-router';
Vue.use(VueRouter);
const routes: Array<RouteConfig> = [
{
path: '/',
name: 'Home',
component: () => import(/* webpackChunkName: "developer-home" */ './views/home.vue'),
},
{
path: '/apps/:appId',
name: 'Application',
component: () => import(/* webpackChunkName: "developer-application" */ './views/application.vue'),
},
{
path: '*',
redirect: '/',
},
];
const router = new VueRouter({
mode: 'history',
base: `${process.env.BASE_URL}developer`,
routes,
});
export default router;

View file

@ -0,0 +1,213 @@
<template>
<v-container class="application-container" v-if="applicationError">
<v-alert type="error">
Failed to load application info
<template #append>
<v-btn light @click="loadApplication">Retry</v-btn>
</template>
</v-alert>
</v-container>
<v-container class="application-container" v-else-if="application === null">
<v-skeleton-loader type="image" class="mb-4" />
<v-skeleton-loader type="image" class="mb-4" />
</v-container>
<v-container class="application-container" v-else>
<div class="mb-4 d-flex align-center">
<div>
<v-badge
:color="application.verified ? 'green' : 'grey'"
offset-x="48"
offset-y="16"
bottom
:content="application.verified ? 'Verified' : 'Not verified'"
>
<app-icon
large
:color="application.iconColor"
:url="application.iconUrl"
/>
</v-badge>
</div>
<div class="text-h5 ml-4 text-right grow">{{ application.name }}</div>
</div>
<v-card outlined class="mb-4">
<v-form @submit.prevent="modifyApp">
<v-card-title>App information</v-card-title>
<v-card-text class="pb-0">
<v-text-field
label="App name"
outlined
v-model="nameInput"
:error-messages="nameError"
counter="32"
:counter-value="(v) => v.trim().length"
/>
<v-text-field
label="Homepage URL (optional)"
outlined
v-model="homepageInput"
:error-messages="homepageError"
/>
</v-card-text>
<v-card-actions class="px-4">
<v-spacer />
<v-btn
color="primary"
:disabled="!appInfoValid"
type="submit"
:loading="appModifyLoading"
>
Save
</v-btn>
</v-card-actions>
</v-form>
<v-divider />
<v-card-title>Icon</v-card-title>
<v-card-text>
Not implemented yet
</v-card-text>
<v-card-actions class="px-4">
<v-spacer />
<v-btn color="primary" outlined disabled>Remove icon</v-btn>
<v-btn color="primary" disabled>Upload</v-btn>
</v-card-actions>
<v-divider />
<v-card-text>
<v-alert type="warning" text :value="application.verified">
You will lose the verified badge
if you change the app information
or upload a new icon
</v-alert>
Users will see your GitHub profile information,
including your name <b>({{ loginState.name || 'not set' }})</b>,
login <b>({{ loginState.login }})</b>, avatar and profile URL
</v-card-text>
</v-card>
<v-card outlined class="mb-4">
<v-card-title>Client IDs</v-card-title>
</v-card>
<v-card outlined class="mb-4">
<v-card-title>Danger zone</v-card-title>
</v-card>
</v-container>
</template>
<style lang="scss">
.application-container {
max-width: 700px;
}
</style>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import AppIcon from '@/pages/app-icon.vue';
import { GetApplicationQuery, LoginState } from '@/graphql/generated';
import { sdk } from '@/graphql/sdk';
@Component({
name: 'DeveloperApplication',
components: { AppIcon },
})
export default class DeveloperApplication extends Vue {
@Prop({
type: Object,
required: true,
})
loginState!: LoginState
application: GetApplicationQuery['application'] | null = null;
applicationError = false;
nameInput = '';
homepageInput = '';
appModifyLoading = false;
appModifyError = false;
get id(): string {
return this.$route.params.appId;
}
get nameError(): string | null {
if (this.nameInput.trim() === '') return 'The name is required';
if (this.nameInput.trim().length < 3) return 'Name too short';
if (this.nameInput.trim().length > 32) return 'Name too long';
return null;
}
get homepageError(): string | null {
if (this.homepageInput.trim() === '') return null;
let url: URL;
try {
url = new URL(this.homepageInput.trim());
} catch {
return 'Invalid URL';
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'Protocol should be http: or https:';
return null;
}
get appInfoValid(): boolean {
if (!this.application) return false;
if (
this.nameInput.trim() === this.application.name
&& this.homepageInput.trim() === (this.application.homepage ?? '')
) return false;
return this.nameError === null && this.homepageError === null;
}
async loadApplication() {
this.application = null;
this.applicationError = false;
try {
const result = await sdk.GetApplication({
id: this.id,
});
if (!result.application) {
console.error('Application not found');
this.applicationError = true;
} else {
this.application = result.application;
this.updateAppInfoInput();
}
} catch (error) {
console.error(error);
this.applicationError = true;
}
}
updateAppInfoInput() {
if (!this.application) return;
this.nameInput = this.application.name;
this.homepageInput = this.application.homepage ?? '';
}
async created() {
await this.loadApplication();
}
async modifyApp() {
if (this.appModifyLoading || !this.appInfoValid) return;
this.appModifyLoading = true;
this.appModifyError = false;
let homepage: string | null = this.homepageInput.trim();
if (homepage === '') homepage = null;
try {
const result = await sdk.ModifyApplication({
id: this.id,
homepage,
name: this.nameInput.trim(),
});
this.application = result.modifyApplication;
this.updateAppInfoInput();
} catch (error) {
console.error(error);
this.appModifyError = true;
}
this.appModifyLoading = false;
}
}
</script>

View file

@ -0,0 +1,99 @@
<template>
<v-container class="home-container">
<div class="text-h4 my-8">
Your applications
</div>
<div class="applications">
<new-app-dialog>
<template #activator="{ on }">
<v-card
outlined
class="d-flex flex-column align-center justify-center text-center"
color="primary--text"
v-on="on"
>
<v-icon color="primary" :size="64">mdi-plus</v-icon>
<div class="text-h5 my-2">New app</div>
</v-card>
</template>
</new-app-dialog>
<v-card
v-if="applicationError"
outlined
color="red--text"
class="d-flex flex-column align-center justify-center text-center"
>
<div class="text-h6 px-2">Failed to load app list</div>
<v-btn class="mt-4" color="primary" @click="loadApplications">Retry</v-btn>
</v-card>
<v-skeleton-loader type="image" v-else-if="applications === null" height="200" />
<v-card
v-else
v-for="app in applications"
:key="app.id"
outlined
:to="`/apps/${app.id}`"
class="d-flex flex-column align-center py-2 text-center"
:style="{
'color': app.iconColor
}"
>
<app-icon
:color="app.iconColor"
:url="app.iconUrl"
class="mx-2"
/>
<v-spacer />
<div class="text-h5 my-2 text--secondary px-2">{{ app.name }}</div>
<v-spacer />
</v-card>
</div>
</v-container>
</template>
<style lang="scss">
.home-container {
max-width: 800px;
.applications {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(250px, 100%), 1fr));
grid-auto-rows: minmax(200px, 1fr);
grid-gap: 10px;
}
}
</style>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
import AppIcon from '@/pages/app-icon.vue';
import NewAppDialog from '@/compontents/developer/new-app-dialog.vue';
import { GetApplicationsQuery } from '@/graphql/generated';
import { sdk } from '@/graphql/sdk';
@Component({
name: 'DeveloperHome',
components: { NewAppDialog, AppIcon },
})
export default class DeveloperHome extends Vue {
applications: GetApplicationsQuery['applications'] | null = null;
applicationError = false;
async loadApplications() {
this.applications = null;
this.applicationError = false;
try {
const result = await sdk.GetApplications();
this.applications = result.applications;
} catch (error) {
console.error(error);
this.applicationError = true;
}
}
created() {
this.loadApplications();
}
}
</script>

View file

@ -0,0 +1,54 @@
<template>
<div>
<div>
<div class="text-h4 text-center mt-8 mx-2">
<span class="primary--text">Wulkanowy</span> Bridge<br>
<span class="text--secondary">Developer Console</span>
</div>
</div>
<v-main class="px-4">
<v-sheet max-width="500" class="mx-auto mt-12" color="transparent">
<v-btn block large dark :href="signInUrl">
<v-icon left>mdi-login</v-icon>
<v-spacer />
Sign in with GitHub
<v-spacer />
</v-btn>
<v-btn block outlined href="/" class="mt-2">
<v-icon left>mdi-arrow-left</v-icon>
<v-spacer />
Go back
<v-spacer />
</v-btn>
<v-divider class="my-8 mx-4" />
<v-btn block color="primary" outlined href="https://github.com/wulkanowy/bridge/wiki">
<v-icon left>mdi-book</v-icon>
<v-spacer />
Documentation
<v-spacer />
</v-btn>
<v-btn class="mt-2" block color="primary" outlined href="https://github.com/wulkanowy/bridge">
<v-icon left>mdi-github</v-icon>
<v-spacer />
Source code
<v-spacer />
</v-btn>
</v-sheet>
</v-main>
</div>
</template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator';
@Component({
name: 'DeveloperSignedOut',
})
export default class DeveloperSignedOut extends Vue {
get signInUrl() {
const url = new URL('/api/website/developer/sign-in/github', window.location.toString());
url.searchParams.set('return_to', `${window.location.pathname}${window.location.search}${window.location.hash}`);
return url.toString();
}
}
</script>

View file

@ -19,7 +19,7 @@ export interface PromptInfo {
iconColor: string;
verified: boolean;
homepage: string | null;
owner: {
developer: {
login: string;
name: string | null;
url: string;

View file

@ -15,5 +15,11 @@ module.exports = {
filename: 'prompt-error.html',
title: 'Błąd autoryzacji | Wulkanowy Bridge',
},
developer: {
entry: 'src/pages/developer/main.ts',
template: '/public/index.html',
filename: 'developer.html',
title: 'Konsola dewelopera | Wulkanowy Bridge',
},
},
};