Compare commits
10 Commits
2359c9c5e9
...
fd216edf50
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd216edf50 | ||
|
|
990d268460 | ||
|
|
fe27c6f918 | ||
|
|
afed523d5b | ||
|
|
40cd25a771 | ||
|
|
3bff98503e | ||
|
|
f9b151d914 | ||
|
|
abd623f2ca | ||
|
|
ec29f8d4b1 | ||
|
|
2362f04704 |
@@ -23,5 +23,6 @@ MAILER_PASSWORD=xxxxx
|
||||
MAILER_FROM='"No Reply" <noreply@example.com>'
|
||||
|
||||
# Client
|
||||
# Url wird für Mailversand gebraucht
|
||||
CLIENT_URL=http://localhost:4200
|
||||
PRODUCTION=true
|
||||
@@ -12,7 +12,7 @@ export class ApplicationController {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('login')
|
||||
@Post('authorize')
|
||||
loginUser(@Body() b: LoginUserDto): Promise<User> {
|
||||
return this.userService.loginUser({
|
||||
username: b.username,
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Client } from 'src/model';
|
||||
import { UsersService } from 'src/shared/users.service';
|
||||
import { CustomLogger } from 'src/shared/logger/custom.logger';
|
||||
|
||||
@Controller('auth')
|
||||
@Controller('')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private usersService: UsersService,
|
||||
@@ -30,7 +30,7 @@ export class AuthController {
|
||||
async login(
|
||||
@Body('username') username: string,
|
||||
@Body('password') password: string,
|
||||
@Body('client_id') clientId: string,
|
||||
@Query('client_id') clientId: string,
|
||||
) {
|
||||
const token = await this.usersService.createToken(
|
||||
username,
|
||||
@@ -57,7 +57,7 @@ export class AuthController {
|
||||
return this.usersService.loginWithSessionKey(code, null, true);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@Get('client')
|
||||
async getClient(
|
||||
@Query('client_id') clientId: string,
|
||||
@Query('response_type') responseType: string,
|
||||
@@ -72,7 +72,7 @@ export class AuthController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post('token')
|
||||
@Post('authorize')
|
||||
@FormDataRequest()
|
||||
async getToken(
|
||||
@Body('client_id') clientId: string,
|
||||
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
DataSource,
|
||||
Repository,
|
||||
CreateDateColumn,
|
||||
Column,
|
||||
BeforeInsert,
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Exclude } from 'class-transformer';
|
||||
|
||||
@Entity()
|
||||
export class SessionKey {
|
||||
@@ -17,8 +20,17 @@ export class SessionKey {
|
||||
@ManyToOne(() => User, (user) => user.sessionKeys, { eager: true })
|
||||
user: User;
|
||||
|
||||
@Exclude()
|
||||
@Column()
|
||||
pwRevision?: number;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@BeforeInsert()
|
||||
setPWRevision() {
|
||||
this.pwRevision = this.user.pwRevision;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -57,6 +57,10 @@ export class User {
|
||||
@ManyToMany(() => Client, (client) => client.admins)
|
||||
clients?: Client[];
|
||||
|
||||
@Exclude()
|
||||
@Column({ type: 'int', default: 0 })
|
||||
pwRevision?: number; // wird hochgezählt wenn das PW geändert wird. somit kann der Token invalid gesetzt werden.
|
||||
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
session_key?: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './authenticated.request';
|
||||
export * from './logger.interface';
|
||||
export * from './mailconfig.interface';
|
||||
export * from './payload.interface';
|
||||
|
||||
18
idp/src/model/interface/payload.interface.ts
Normal file
18
idp/src/model/interface/payload.interface.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export interface IAccessPayload {
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
id: string;
|
||||
iss: string;
|
||||
aud: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export interface IRefreshPayload {
|
||||
type: string;
|
||||
id: string;
|
||||
token_revision: number;
|
||||
iat: number;
|
||||
exp: number;
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
AuthorizationCodeRepository,
|
||||
AuthorizationCode,
|
||||
ActivityLogRepository,
|
||||
IAccessPayload,
|
||||
IRefreshPayload,
|
||||
} from 'src/model';
|
||||
import { CustomLogger } from './logger/custom.logger';
|
||||
|
||||
@@ -123,6 +125,10 @@ export class UsersService {
|
||||
throw new HttpException('User is not active', 401);
|
||||
}
|
||||
|
||||
if (user.pwRevision != session.pwRevision) {
|
||||
throw new HttpException('Invalid session key', 401);
|
||||
}
|
||||
|
||||
if (getUserAccessToken) {
|
||||
user.accessToken = this.createAccessToken(user);
|
||||
user.refreshToken = this.createRefreshToken(user);
|
||||
@@ -205,25 +211,30 @@ export class UsersService {
|
||||
{
|
||||
type: 'refresh',
|
||||
id: user.id,
|
||||
token_revision: user.pwRevision,
|
||||
},
|
||||
{ expiresIn: '365d' },
|
||||
);
|
||||
}
|
||||
|
||||
async getNewAccessToken(refreshToken: string) {
|
||||
const payload = this.jwtService.verify(refreshToken);
|
||||
const payload: IRefreshPayload = this.jwtService.verify(refreshToken);
|
||||
if (payload.type !== 'refresh') {
|
||||
this.logger.error(`Token ${refreshToken} is not a refresh token`);
|
||||
throw new HttpException('Invalid token', 401);
|
||||
}
|
||||
const user = await this.userRepo.findById(payload.id);
|
||||
if (!user) {
|
||||
if (
|
||||
!user ||
|
||||
payload['token_revision'] == undefined ||
|
||||
payload['token_revision'] != user.pwRevision
|
||||
) {
|
||||
this.logger.error(`User ${payload.id} not found for refresh token`);
|
||||
throw new HttpException('Invalid token', 401);
|
||||
}
|
||||
|
||||
const token = this.createAccessToken(user);
|
||||
const pay = this.jwtService.decode(token);
|
||||
const pay: IAccessPayload = this.jwtService.decode(token);
|
||||
const result = {
|
||||
access_token: token,
|
||||
expires_in: pay.exp - pay.iat,
|
||||
@@ -237,6 +248,7 @@ export class UsersService {
|
||||
try {
|
||||
const decoded = this.jwtService.verify(token);
|
||||
this.activityRepo.logAccessTokenVerification();
|
||||
console.log(decoded, '-');
|
||||
return decoded;
|
||||
} catch (e) {
|
||||
this.logger.error(`Token ${token} is invalid. Error: ${e.message}`);
|
||||
@@ -303,6 +315,7 @@ export class UsersService {
|
||||
if (savedCode && savedCode.user) {
|
||||
const hashedPassword = await bcrypt.hash(dto.password, 10);
|
||||
savedCode.user.password = hashedPassword;
|
||||
savedCode.user.pwRevision += 1;
|
||||
await this.userRepo.save(savedCode.user);
|
||||
await this.resetPwRepo.remove(savedCode);
|
||||
await this.sessionRepo.delete({ user: { id: savedCode.user.id } });
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ResetPwComponent } from './auth/reset-pw/reset-pw.component';
|
||||
|
||||
export const routes: Routes = [
|
||||
{ path: 'login', component: LoginComponent, canActivate: [SessionKeyGuard] },
|
||||
{ path: 'authorize', component: LoginComponent, canActivate: [SessionKeyGuard] },
|
||||
{ path: 'register', component: RegisterComponent },
|
||||
{ path: 'pw-reset', component: ResetPwComponent },
|
||||
{ path: 'dashboard', component: DashboardComponent, canActivate: [SessionKeyGuard] },
|
||||
|
||||
@@ -125,6 +125,9 @@ body {
|
||||
outline: none;
|
||||
border-bottom-color: #6A679E;
|
||||
}
|
||||
.login__input_error {
|
||||
border-bottom: 2px solid #b61d09;
|
||||
}
|
||||
|
||||
.login__submit {
|
||||
background: #fff;
|
||||
|
||||
@@ -66,8 +66,8 @@ export class LoginComponent {
|
||||
this.client_id = params.client_id;
|
||||
|
||||
if (!this.client_id) { return; }
|
||||
|
||||
this.http.get<any>('api/auth/', {
|
||||
console.log(params);
|
||||
this.http.get<any>('api/client', {
|
||||
params
|
||||
}).subscribe({
|
||||
next: (client) => {
|
||||
@@ -82,7 +82,7 @@ export class LoginComponent {
|
||||
|
||||
login() {
|
||||
this.isLoading = true;
|
||||
const url = this.client_id ? `api/auth/login?client_id=${this.client_id}` : 'api/app/login';
|
||||
const url = this.client_id ? `api/login?client_id=${this.client_id}` : 'api/app/authorize';
|
||||
console.log(url, this.client_id)
|
||||
this.http.post(url, this.loginForm.value).
|
||||
pipe(
|
||||
|
||||
@@ -13,15 +13,15 @@
|
||||
</div>
|
||||
<div class="login__field">
|
||||
<i class="login__icon fas fa-user user"></i>
|
||||
<input formControlName="username" type="text" class="login__input" autocomplete="email" placeholder="User name / Email">
|
||||
<input formControlName="username" type="text" class="login__input" autocomplete="email" placeholder="User name / Email" [class.login__input_error]="registerForm.controls.username.touched && registerForm.controls.username.invalid">
|
||||
</div>
|
||||
<div class="login__field">
|
||||
<i class="login__icon fas fa-lock safe"></i>
|
||||
<input type="password" formControlName="password" autocomplete="new-password" class="login__input" placeholder="Password">
|
||||
<input type="password" formControlName="password" autocomplete="new-password" class="login__input" placeholder="Password" [class.login__input_error]="registerForm.controls.password.touched && registerForm.controls.password.invalid">
|
||||
</div>
|
||||
<div class="login__field">
|
||||
<i class="login__icon fas fa-lock safe"></i>
|
||||
<input type="password" formControlName="repeatPassword" autocomplete="new-password" class="login__input" placeholder="Repeat password">
|
||||
<input type="password" formControlName="repeatPassword" autocomplete="new-password" class="login__input" placeholder="Repeat password" [class.login__input_error]="registerForm.controls.repeatPassword.touched && registerForm.controls.repeatPassword.invalid">
|
||||
</div>
|
||||
<button class="button login__submit" (click)="register()" [disabled]="!client_id || registerForm.invalid">
|
||||
<span class="button__text">Register</span>
|
||||
|
||||
@@ -24,8 +24,8 @@ export class RegisterComponent {
|
||||
|
||||
registerForm = new FormGroup({
|
||||
username: new FormControl('', [Validators.required, Validators.email, Validators.maxLength(100)]),
|
||||
password: new FormControl('', [Validators.required, Validators.minLength(6), Validators.maxLength(20)]),
|
||||
repeatPassword: new FormControl('', [Validators.required, Validators.minLength(6), Validators.maxLength(20)]),
|
||||
password: new FormControl('', [Validators.required, Validators.minLength(6), Validators.maxLength(64)]),
|
||||
repeatPassword: new FormControl('', [Validators.required, Validators.minLength(6), Validators.maxLength(64)]),
|
||||
firstName: new FormControl('', [Validators.required, Validators.maxLength(100)]),
|
||||
lastName: new FormControl('', [Validators.required, Validators.maxLength(100)]),
|
||||
})
|
||||
@@ -39,7 +39,7 @@ export class RegisterComponent {
|
||||
const params = (this.route.snapshot.queryParamMap as any)["params"];
|
||||
this.redirectUri = params.redirect_uri;
|
||||
this.client_id = params.client_id;
|
||||
this.http.get<any>('api/auth/', {
|
||||
this.http.get<any>('api/client', {
|
||||
params
|
||||
}).subscribe({
|
||||
next: (client) => {
|
||||
@@ -57,7 +57,7 @@ export class RegisterComponent {
|
||||
this.toast.error('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
this.http.post('api/auth/register?'+ 'client_id=' + this.client_id, this.registerForm.value).pipe(
|
||||
this.http.post('api/register?'+ 'client_id=' + this.client_id, this.registerForm.value).pipe(
|
||||
this.toast.observe({
|
||||
loading: 'Registering...',
|
||||
success: 'Registration successfull, please log in',
|
||||
|
||||
@@ -38,7 +38,7 @@ export class ResetPwComponent {
|
||||
|
||||
|
||||
resetPassword() {
|
||||
this.http.post('api/auth/reset', this.resetPw.value)
|
||||
this.http.post('api/reset', this.resetPw.value)
|
||||
.pipe(
|
||||
this.toast.observe({
|
||||
loading: 'Sende Mail...',
|
||||
@@ -59,7 +59,7 @@ export class ResetPwComponent {
|
||||
this.toast.error('Die Passwörter stimmen nicht überein');
|
||||
return;
|
||||
}
|
||||
this.http.post('api/auth/reset', this.setNewPwForm.value)
|
||||
this.http.post('api/reset', this.setNewPwForm.value)
|
||||
.pipe(
|
||||
this.toast.observe({
|
||||
loading: 'Setze neues Passwort',
|
||||
|
||||
@@ -40,7 +40,7 @@ export class SessionKeyGuard {
|
||||
const id = window.localStorage.getItem("auth_session_key");
|
||||
if (!id ||id.length < 2) { return resolve(true); }
|
||||
|
||||
const url = this.client_id ? 'api/auth/login-with-session-id' : 'api/auth/login-with-session-id/userlogin'
|
||||
const url = this.client_id ? 'api/login-with-session-id' : 'api/login-with-session-id/userlogin'
|
||||
|
||||
this.http.post(url, {
|
||||
code: id,
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<div mat-dialog-title>Hilfe</div>
|
||||
<mat-dialog-content>
|
||||
<div class="mat-body">
|
||||
<h3>Authentifizierung:</h3>
|
||||
<div>Für den Login:</div>
|
||||
<code>https://sso.beantastic.de/authorize</code>
|
||||
|
||||
<h5>Query:</h5>
|
||||
<table>
|
||||
<tr>
|
||||
<td>response_type</td>
|
||||
<td>code</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>client_id</td>
|
||||
<td><CLIENT ID></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>redirect_uri</td>
|
||||
<td><REDIRECT URI></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>scope</td>
|
||||
<td><SCOPE></td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
<br />
|
||||
<div>
|
||||
Danach wird der user mit einem auth code als parameter <code>?code=<b><AUTH_CODE></b></code> zurückgeleitet.
|
||||
</div>
|
||||
<h3>Code => Accesstoken:</h3>
|
||||
<div>den Code tauscht der Client gegen den Accesstoken:</div>
|
||||
<code>POST: https://sso.beantastic.de/api/authorize</code>
|
||||
<h5>Body (Form):</h5>
|
||||
<table>
|
||||
<tr>
|
||||
<td>client_id</td>
|
||||
<td><CLIENT ID></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>client_secret</td>
|
||||
<td><Secret></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>code</td>
|
||||
<td><Auth Code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>grant_type</td>
|
||||
<td>authorization code</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>Accesstoken prüfen:</h3>
|
||||
<div>Einen Accesstoken verifizieren:</div>
|
||||
<code>POST: https://sso.beantastic.de/api/verify</code>
|
||||
<h5>Body (Form):</h5>
|
||||
<table>
|
||||
<tr>
|
||||
<td>access_token</td>
|
||||
<td><Access Token></td>
|
||||
</tr>
|
||||
</table>
|
||||
<h5>Return:</h5>
|
||||
Decoded Token
|
||||
|
||||
<h3>neuen Accesstoken:</h3>
|
||||
<div>um einen Refreshtoken in einen Accesstoken zu tauschen:</div>
|
||||
<code>POST: https://sso.beantastic.de/api/authorize</code>
|
||||
<h5>Body (Form):</h5>
|
||||
<table>
|
||||
<tr>
|
||||
<td>client_id</td>
|
||||
<td><CLIENT_ID></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>code</td>
|
||||
<td><Refresh Token></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>grant_type</td>
|
||||
<td>refreshtoken</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</mat-dialog-content>
|
||||
|
||||
<mat-dialog-actions>
|
||||
<button mat-button mat-dialog-close >Schließen</button>
|
||||
</mat-dialog-actions>
|
||||
@@ -0,0 +1,7 @@
|
||||
h1, h2, h3, h4, h5 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h5 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { HelpComponent } from './help.component';
|
||||
|
||||
describe('HelpComponent', () => {
|
||||
let component: HelpComponent;
|
||||
let fixture: ComponentFixture<HelpComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HelpComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(HelpComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
|
||||
@Component({
|
||||
selector: 'app-help',
|
||||
standalone: true,
|
||||
imports: [MatDialogModule, MatButtonModule],
|
||||
templateUrl: './help.component.html',
|
||||
styleUrl: './help.component.scss'
|
||||
})
|
||||
export class HelpComponent {
|
||||
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
<div class="header">
|
||||
<div class="title">SSO Beantastic</div>
|
||||
<div class="flex-row">
|
||||
<div class="question" (click)="openHelp()"></div>
|
||||
|
||||
<div> {{ userName }}</div>
|
||||
<div class="logout" (click)="logout()" ></div>
|
||||
|
||||
<div class="logout" (click)="logout()" ></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logout{
|
||||
.logout, .question {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CreateClientComponent } from './components/create-client/create-client.
|
||||
import { CreateHotToastRef, HotToastService } from '@ngxpert/hot-toast';
|
||||
import {MatBottomSheet, MatBottomSheetModule, MatBottomSheetRef} from '@angular/material/bottom-sheet';
|
||||
import { LoginChartComponent } from './components/charts/login/login.chart.component';
|
||||
import { HelpComponent } from './components/help/help.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-dashboard',
|
||||
@@ -38,7 +39,6 @@ export class DashboardComponent implements OnInit {
|
||||
this.router.navigateByUrl("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
this.load();
|
||||
}
|
||||
|
||||
@@ -123,6 +123,10 @@ export class DashboardComponent implements OnInit {
|
||||
logout() {
|
||||
this.userService.logout();
|
||||
}
|
||||
|
||||
openHelp() {
|
||||
this.dialog.open(HelpComponent)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
1
idp_client/src/assets/icons/question.svg
Normal file
1
idp_client/src/assets/icons/question.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg enable-background="new 0 0 512 512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><g id="_x32_9_question"><g><path d="m72.632 129.761h178.052c2.702 0 4.888-2.186 4.888-4.888s-2.186-4.888-4.888-4.888h-178.052c-2.702 0-4.888 2.186-4.888 4.888s2.186 4.888 4.888 4.888z"/><path d="m72.632 177.873h108.583c2.702 0 4.888-2.186 4.888-4.888s-2.186-4.888-4.888-4.888h-108.583c-2.702 0-4.888 2.186-4.888 4.888s2.186 4.888 4.888 4.888z"/><path d="m72.632 225.984h217.16c2.702 0 4.888-2.186 4.888-4.888s-2.186-4.888-4.888-4.888h-217.16c-2.702 0-4.888 2.186-4.888 4.888-.001 2.702 2.186 4.888 4.888 4.888z"/><path d="m72.632 274.091h217.16c2.702 0 4.888-2.186 4.888-4.888s-2.186-4.888-4.888-4.888h-217.16c-2.702 0-4.888 2.186-4.888 4.888s2.186 4.888 4.888 4.888z"/><path d="m210.034 312.425h-137.402c-2.702 0-4.888 2.186-4.888 4.888s2.186 4.888 4.888 4.888h137.403c2.702 0 4.888-2.186 4.888-4.888s-2.187-4.888-4.889-4.888z"/><path d="m210.034 360.532h-137.402c-2.702 0-4.888 2.186-4.888 4.888s2.186 4.888 4.888 4.888h137.403c2.702 0 4.888-2.186 4.888-4.888s-2.187-4.888-4.889-4.888z"/><path d="m379.756 269.501v-165.91c0-1.047-.532-2.599-1.461-3.509l-92.685-90.829c-.881-.863-2.384-1.389-3.461-1.389h-216.754c-20.165 0-36.568 16.403-36.568 36.568v379.788c0 20.165 16.403 36.568 36.568 36.568h208.864c21.614 26.436 54.462 43.347 91.195 43.347 64.911 0 117.72-52.828 117.72-117.768 0-60.092-45.232-109.779-103.418-116.866zm-92.68-245.078 75.799 74.26h-66.562c-5.094 0-9.238-4.168-9.238-9.29v-64.97zm-221.681 426.589c-14.77 0-26.791-12.021-26.791-26.791v-379.789c0-14.77 12.021-26.791 26.791-26.791h211.904v71.752c0 10.512 8.531 19.067 19.014 19.067h73.666v160.256c-1.504-.057-3.008-.115-4.526-.115-64.939 0-117.768 52.828-117.768 117.768 0 23.861 7.153 46.073 19.397 64.643zm300.059 43.347c-59.545 0-107.991-48.446-107.991-107.991s48.446-107.991 107.991-107.991c59.521 0 107.943 48.446 107.943 107.991s-48.422 107.991-107.943 107.991z"/><path d="m367.354 319.553h-3.848c-19.707 0-35.737 16.036-35.737 35.742 0 2.702 2.186 4.888 4.888 4.888s4.888-2.186 4.888-4.888c0-14.317 11.644-25.965 25.961-25.965h3.848c14.317 0 25.965 11.648 25.965 25.965 0 28.204-25.253 36.571-26.419 37.571-8.24 4.187-13.362 12.522-13.362 21.764v11.534c0 2.702 2.186 4.888 4.888 4.888s4.888-2.186 4.888-4.888v-11.534c0-5.538 3.07-10.536 8.011-13.047 1.123-.978 31.77-11.54 31.77-46.288.001-19.707-16.035-35.742-35.741-35.742z"/><path d="m358.36 440.514c-2.702 0-4.888 2.186-4.888 4.888v2.893c0 2.702 2.186 4.888 4.888 4.888s4.888-2.186 4.888-4.888v-2.893c0-2.702-2.186-4.888-4.888-4.888z"/></g></g><g id="Layer_1"/></svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -20,6 +20,10 @@ html, body {
|
||||
background-image: url("assets/icons/logout.svg");
|
||||
}
|
||||
|
||||
.question {
|
||||
background-image: url("assets/icons/question.svg");
|
||||
}
|
||||
|
||||
.flex-row{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
Reference in New Issue
Block a user