More work on perms page

This commit is contained in:
simon987 2019-02-17 14:09:52 -05:00
parent b936513eb9
commit 94c3ce3267
30 changed files with 425 additions and 70 deletions

View File

@ -155,6 +155,32 @@ func (api *WebAPI) GetManagerList(r *Request) {
})
}
func (api *WebAPI) GetManagerListWithRoleOn(r *Request) {
pid, err := strconv.ParseInt(r.Ctx.UserValue("id").(string), 10, 64)
handleErr(err, r) //todo handle invalid id
sess := api.Session.StartFasthttp(r.Ctx)
manager := sess.Get("manager")
if manager == nil {
r.Json(JsonResponse{
Ok: false,
Message: "Unauthorized",
}, 401)
return
}
managers := api.Database.GetManagerListWithRoleOn(pid)
r.OkJson(JsonResponse{
Ok: true,
Content: GetManagerListWithRoleOnResponse{
Managers: managers,
},
})
}
func (api *WebAPI) PromoteManager(r *Request) {
id, err := strconv.ParseInt(r.Ctx.UserValue("id").(string), 10, 64)

View File

@ -106,8 +106,10 @@ func New() *WebAPI {
api.router.GET("/logout", LogRequestMiddleware(api.Logout))
api.router.GET("/account", LogRequestMiddleware(api.GetAccountDetails))
api.router.GET("/manager/list", LogRequestMiddleware(api.GetManagerList))
api.router.GET("/manager/list_for_project/:id", LogRequestMiddleware(api.GetManagerListWithRoleOn))
api.router.GET("/manager/promote/:id", LogRequestMiddleware(api.PromoteManager))
api.router.GET("/manager/demote/:id", LogRequestMiddleware(api.DemoteManager))
api.router.POST("/manager/set_role_for_project/:id", LogRequestMiddleware(api.SetManagerRoleOnProject))
api.router.NotFound = func(ctx *fasthttp.RequestCtx) {

View File

@ -81,6 +81,10 @@ type GetManagerListResponse struct {
Managers *[]storage.Manager `json:"managers"`
}
type GetManagerListWithRoleOnResponse struct {
Managers *[]storage.ManagerRoleOn `json:"managers"`
}
type GetLogRequest struct {
Level storage.LogLevel `json:"level"`
Since int64 `json:"since"`
@ -263,6 +267,11 @@ func (w *CreateWorkerAccessRequest) isValid() bool {
return true
}
type SetManagerRoleOnProjectRequest struct {
Manager int64 `json:"manager"`
Role storage.ManagerRole `json:"role"`
}
type Info struct {
Name string `json:"name"`
Version string `json:"version"`

View File

@ -100,7 +100,7 @@ func (api *WebAPI) CreateProject(r *Request) {
return
}
api.Database.SetManagerRoleOn(manager.(*storage.Manager), id,
api.Database.SetManagerRoleOn(manager.(*storage.Manager).Id, id,
storage.ROLE_MANAGE_ACCESS|storage.ROLE_READ|storage.ROLE_EDIT)
r.OkJson(JsonResponse{
Ok: true,
@ -403,3 +403,35 @@ func (api *WebAPI) RejectAccessRequest(r *Request) {
})
}
}
func (api *WebAPI) SetManagerRoleOnProject(r *Request) {
pid, err := strconv.ParseInt(r.Ctx.UserValue("id").(string), 10, 64)
handleErr(err, r) //todo handle invalid id
req := &SetManagerRoleOnProjectRequest{}
err = json.Unmarshal(r.Ctx.Request.Body(), req)
if err != nil {
r.Json(JsonResponse{
Ok: false,
Message: "Could not parse request",
}, 400)
return
}
sess := api.Session.StartFasthttp(r.Ctx)
manager := sess.Get("manager")
if !isActionOnProjectAuthorized(pid, manager, storage.ROLE_MANAGE_ACCESS, api.Database) {
r.Json(JsonResponse{
Message: "Unauthorized",
Ok: false,
}, 403)
return
}
api.Database.SetManagerRoleOn(req.Manager, pid, req.Role)
r.OkJson(JsonResponse{
Ok: true,
})
}

View File

@ -3,6 +3,7 @@ package storage
import (
"bytes"
"crypto"
"database/sql"
"errors"
"github.com/Sirupsen/logrus"
)
@ -23,6 +24,11 @@ type Manager struct {
RegisterTime int64 `json:"register_time"`
}
type ManagerRoleOn struct {
Manager Manager `json:"manager"`
Role ManagerRole `json:"role"`
}
func (database *Database) ValidateCredentials(username []byte, password []byte) (*Manager, error) {
db := database.getDB()
@ -142,20 +148,28 @@ func (database *Database) GetManagerRoleOn(manager *Manager, projectId int64) Ma
return role
}
func (database *Database) SetManagerRoleOn(manager *Manager, projectId int64, role ManagerRole) {
func (database *Database) SetManagerRoleOn(manager int64, projectId int64, role ManagerRole) {
db := database.getDB()
res, err := db.Exec(`INSERT INTO manager_has_role_on_project (manager, role, project)
VALUES ($1,$2,$3) ON CONFLICT (manager, project) DO UPDATE SET role=$2`,
manager.Id, role, projectId)
handleErr(err)
var res sql.Result
var err error
if role == 0 {
res, err = db.Exec(`DELETE FROM manager_has_role_on_project WHERE manager=$1 AND project=$2`,
manager, projectId)
} else {
res, err = db.Exec(`INSERT INTO manager_has_role_on_project (manager, role, project)
VALUES ($1,$2,$3) ON CONFLICT (manager, project) DO UPDATE SET role=$2`,
manager, role, projectId)
}
handleErr(err)
rowsAffected, _ := res.RowsAffected()
logrus.WithFields(logrus.Fields{
"role": role,
"manager": manager.Username,
"manager": manager,
"rowsAffected": rowsAffected,
"project": projectId,
}).Info("Set manager role on project")
@ -177,3 +191,29 @@ func (database *Database) GetManagerList() *[]Manager {
return &managers
}
func (database *Database) GetManagerListWithRoleOn(project int64) *[]ManagerRoleOn {
db := database.getDB()
rows, err := db.Query(`SELECT id, register_time, tracker_admin, username, role
FROM manager
LEFT JOIN manager_has_role_on_project mhrop on
manager.id = mhrop.manager
WHERE project=$1 ORDER BY id`, project)
handleErr(err)
managers := make([]ManagerRoleOn, 0)
for rows.Next() {
m := Manager{}
var role ManagerRole
_ = rows.Scan(&m.Id, &m.RegisterTime, &m.WebsiteAdmin, &m.Username, &role)
managers = append(managers, ManagerRoleOn{
Manager: m,
Role: role,
})
}
return &managers
}

View File

@ -69,10 +69,14 @@ export class ApiService {
return this.http.get(this.url + `/project/access_list/${project}`)
}
getAllManagers() {
getManagerList() {
return this.http.get(this.url + "/manager/list")
}
getManagerListWithRoleOn(project: number) {
return this.http.get(this.url + "/manager/list_for_project/" + project)
}
promote(managerId: number) {
return this.http.get(this.url + `/manager/promote/${managerId}`)
}
@ -89,4 +93,9 @@ export class ApiService {
return this.http.post(this.url + `/project/reject_request/${pid}/${wid}`, null)
}
setManagerRoleOnProject(pid: number, role: number, manager: number) {
return this.http.post(this.url + `/manager/set_role_for_project/${pid}`,
{"role": role, "manager": manager})
}
}

View File

@ -1,7 +1,3 @@
.nav-spacer {
flex: 1 1 auto;
}
.icon {
padding: 0 14px;
}

View File

@ -38,7 +38,7 @@
>{{"nav.manager_list" | translate}}</button>
</mat-menu>
</div>
<span class="nav-spacer"></span>
<span class="spacer"></span>
<button mat-button [class.mat-accent]="router.url == '/login'"
class="nav-link" *ngIf="!authService.account"
[routerLink]="'login'">{{"nav.login" | translate}}</button>

View File

@ -50,6 +50,8 @@ import {WorkerDashboardComponent} from './worker-dashboard/worker-dashboard.comp
import {ProjectPermsComponent} from './project-perms/project-perms.component';
import {ManagerListComponent} from './manager-list/manager-list.component';
import {ProjectSelectComponent} from './project-select/project-select.component';
import {ManagerSelectComponent} from './manager-select/manager-select.component';
import {ProjectIconComponent} from './project-icon/project-icon.component';
export function createTranslateLoader(http: HttpClient) {
@ -72,6 +74,8 @@ export function createTranslateLoader(http: HttpClient) {
ProjectPermsComponent,
ManagerListComponent,
ProjectSelectComponent,
ManagerSelectComponent,
ProjectIconComponent,
],
imports: [
BrowserModule,

View File

@ -3,6 +3,7 @@ import {ApiService} from "./api.service";
import {Credentials} from "./models/credentials";
import {MessengerService} from "./messenger.service";
import {Router} from "@angular/router";
import {Manager} from "./models/manager";
@Injectable({
providedIn: 'root'

View File

@ -6,13 +6,20 @@
<mat-card-content>
<mat-form-field appearance="outline">
<mat-label>{{"project.name" | translate}}</mat-label>
<input type="text" matInput [(ngModel)]="project.name" [placeholder]="'project.name' | translate">
<input type="text" matInput [(ngModel)]="project.name" [placeholder]="'project.name' | translate"
required>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ "project.clone_url" | translate}}</mat-label>
<input type="text" matInput [(ngModel)]="project.clone_url" (change)="cloneUrlChange()"
[placeholder]="'project.clone_url_placeholder' | translate">
[placeholder]="'project.clone_url_placeholder' | translate" required>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{"project.version" | translate}}</mat-label>
<input type="text" matInput [(ngModel)]="project.version" name="version"
[placeholder]="'project.version'|translate"
>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ "project.git_repo" | translate }}</mat-label>

View File

@ -55,7 +55,7 @@ export class ManagerListComponent implements OnInit {
}
private getManagers() {
this.apiService.getAllManagers()
this.apiService.getManagerList()
.subscribe(data => {
this.data.data = data["content"]["managers"]
},

View File

@ -0,0 +1,3 @@
.mat-form-field {
width: 100%;
}

View File

@ -0,0 +1,16 @@
<mat-form-field appearance="outline" style="margin-top: 1em">
<mat-label>{{"project.manager_select" | translate}}</mat-label>
<mat-select [(ngModel)]="manager" (selectionChange)="managerChange.emit($event.value)"
[placeholder]="'perms.manager_select' | translate"
(opened)="loadManagerList()">
<mat-select-trigger></mat-select-trigger>
<mat-option disabled *ngIf="managerList == undefined">
{{"project_select.loading" | translate}}
</mat-option>
<mat-option *ngFor="let m of managerList" [value]="m">
<mat-icon *ngIf="m.tracker_admin">supervisor_account</mat-icon>
<mat-icon *ngIf="!m.tracker_admin">person</mat-icon>
{{m.username}}
</mat-option>
</mat-select>
</mat-form-field>

View File

@ -0,0 +1,30 @@
import {Component, EventEmitter, OnInit, Output} from '@angular/core';
import {ApiService} from "../api.service";
import {Manager} from "../models/manager";
@Component({
selector: 'manager-select',
templateUrl: './manager-select.component.html',
styleUrls: ['./manager-select.component.css']
})
export class ManagerSelectComponent implements OnInit {
manager: Manager;
managerList: Manager[];
@Output()
managerChange = new EventEmitter<Manager>();
constructor(private apiService: ApiService) {
}
ngOnInit() {
}
loadManagerList() {
this.apiService.getManagerList()
.subscribe(data => this.managerList = data["content"]["managers"])
}
}

View File

@ -1,6 +1,54 @@
interface Manager {
export interface Manager {
id: number;
username: string
tracker_admin: boolean
register_time: number
}
export class ManagerRoleOnProject {
manager: Manager;
role: number;
public static fromEntity(data: { role: number, manager: Manager }): ManagerRoleOnProject {
let m = new ManagerRoleOnProject();
m.role = data.role;
m.manager = data.manager;
return m;
}
get readRole(): boolean {
return (this.role & 1) != 0
}
set readRole(role: boolean) {
if (role) {
this.role |= 1
} else {
this.role &= ~1
}
}
get editRole(): boolean {
return (this.role & 2) != 0
}
set editRole(role: boolean) {
if (role) {
this.role |= 2
} else {
this.role &= ~2
}
}
get manageRole(): boolean {
return (this.role & 4) != 0
}
set manageRole(role: boolean) {
if (role) {
this.role |= 4
} else {
this.role &= ~4
}
}
}

View File

@ -5,6 +5,8 @@ import {ActivatedRoute} from "@angular/router";
import {Chart} from "chart.js";
import {AssignedTasks, MonitoringSnapshot} from "../models/monitoring";
import {TranslateService} from "@ngx-translate/core";
import {MessengerService} from "../messenger.service";
@Component({
@ -41,7 +43,10 @@ export class ProjectDashboardComponent implements OnInit {
lastSnapshot: MonitoringSnapshot;
assignees: AssignedTasks[];
constructor(private apiService: ApiService, private route: ActivatedRoute) {
constructor(private apiService: ApiService,
private route: ActivatedRoute,
private translate: TranslateService,
private messenger: MessengerService) {
}
ngOnInit(): void {
@ -327,6 +332,10 @@ export class ProjectDashboardComponent implements OnInit {
this.setupAssigneesPie();
});
})
},
error => {
this.translate.get("messenger.unauthorized").subscribe(t =>
this.messenger.show(t))
})
}
}

View File

@ -0,0 +1,3 @@
<mat-icon *ngIf="project.public" [title]="'project.public' | translate">public</mat-icon>
<mat-icon *ngIf="!project.public && !project.hidden" [title]="'project.private'|translate">lock</mat-icon>
<mat-icon *ngIf="project.hidden" [title]="'project.hidden'|translate">visibility_off</mat-icon>

View File

@ -0,0 +1,21 @@
import {Component, Input, OnInit} from '@angular/core';
import {Project} from "../models/project";
@Component({
selector: 'project-icon',
templateUrl: './project-icon.component.html',
styleUrls: ['./project-icon.component.css']
})
export class ProjectIconComponent implements OnInit {
@Input()
project: Project;
constructor() {
}
ngOnInit() {
}
}

View File

@ -2,6 +2,6 @@ button {
margin-right: 15px;
}
mat-panel-title > mat-icon {
mat-panel-title > project-icon {
margin-right: 1em;
}

View File

@ -13,11 +13,7 @@
<mat-expansion-panel *ngFor="let project of projects" style="margin-top: 1em">
<mat-expansion-panel-header>
<mat-panel-title>
<mat-icon *ngIf="project.public" [title]="'project.public' | translate">public</mat-icon>
<mat-icon *ngIf="!project.public && !project.hidden" [title]="'project.private'|translate">
lock
</mat-icon>
<mat-icon *ngIf="project.hidden" [title]="'project.hidden'|translate">block</mat-icon>
<project-icon [project]="project"></project-icon>
<span style="width: 3em">{{project.id}}</span>{{project.name}}
</mat-panel-title>
<mat-panel-description>{{project.motd}}</mat-panel-description>

View File

@ -6,3 +6,7 @@ button {
.request {
color: #757575;
}
mat-checkbox {
margin-right: 10px;
}

View File

@ -9,8 +9,9 @@
<mat-card-subtitle>{{"perms.subtitle" | translate}}</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<mat-list *ngIf="!unauthorized">
<mat-card-content *ngIf="!unauthorized || !auth.account">
<h3>{{"perms.workers" | translate}}</h3>
<mat-list *ngIf="accesses && accesses.length>0">
<mat-list-item *ngFor="let wa of accesses" [class.request]="wa.request">
<mat-icon mat-list-icon *ngIf="wa.submit" [title]="'perms.assign'|translate">library_add</mat-icon>
<mat-icon mat-list-icon *ngIf="wa.assign" [title]="'perms.submit'|translate">get_app</mat-icon>
@ -32,7 +33,33 @@
</button>
</mat-list-item>
</mat-list>
<p *ngIf="unauthorized" class="unauthorized">
<p *ngIf="!accesses || accesses.length == 0">{{"perms.no_workers"|translate}}</p>
<h3>{{"perms.managers" | translate}}</h3>
<manager-select (managerChange)="onSelectManager($event)"></manager-select>
<mat-list>
<mat-list-item *ngFor="let m of managerRoles">
<mat-icon *ngIf="m.manager.tracker_admin">supervisor_account</mat-icon>
<mat-icon *ngIf="!m.manager.tracker_admin">person</mat-icon>
{{m.manager.username}}
<span class="spacer"></span>
<mat-checkbox [(ngModel)]="m.readRole"
(change)="onRoleChange(m)"
[disabled]="m.manager.id==auth.account.id"
>{{"perms.read"|translate}}</mat-checkbox>
<mat-checkbox [(ngModel)]="m.editRole"
(change)="onRoleChange(m)"
[disabled]="m.manager.id==auth.account.id"
>{{"perms.edit"|translate}}</mat-checkbox>
<mat-checkbox [(ngModel)]="m.manageRole"
(change)="onRoleChange(m)"
[disabled]="m.manager.id==auth.account.id"
>{{"perms.manage"|translate}}</mat-checkbox>
</mat-list-item>
</mat-list>
</mat-card-content>
<mat-card-content *ngIf="unauthorized">
<p class="unauthorized">
<mat-icon>block</mat-icon>
{{"perms.unauthorized" | translate}}
</p>

View File

@ -1,12 +1,14 @@
import {Component, OnInit} from '@angular/core';
import {ApiService} from "../api.service";
import {Project} from "../models/project";
import {ActivatedRoute, Router} from "@angular/router";
import {MessengerService} from "../messenger.service";
import {TranslateService} from "@ngx-translate/core";
import {ActivatedRoute} from "@angular/router";
import * as moment from "moment"
import {WorkerAccess} from "../models/worker-access";
import {AuthService} from "../auth.service";
import {Manager, ManagerRoleOnProject} from "../models/manager";
import {MessengerService} from "../messenger.service";
import {TranslateService} from "@ngx-translate/core";
@Component({
selector: 'app-project-perms',
@ -17,14 +19,15 @@ export class ProjectPermsComponent implements OnInit {
constructor(private apiService: ApiService,
private route: ActivatedRoute,
private messengerService: MessengerService,
private translate: TranslateService,
private router: Router) {
private messenger: MessengerService,
public auth: AuthService) {
}
project: Project;
private projectId: number;
accesses: WorkerAccess[];
managerRoles: ManagerRoleOnProject;
unauthorized: boolean = false;
moment = moment;
@ -33,17 +36,24 @@ export class ProjectPermsComponent implements OnInit {
this.projectId = params["id"];
this.getProject();
this.getProjectAccesses();
this.getProjectManagers();
})
}
public acceptRequest(wa: WorkerAccess) {
this.apiService.acceptWorkerAccessRequest(wa.worker.id, this.projectId)
.subscribe(() => this.getProjectAccesses())
.subscribe(() => {
this.getProjectAccesses();
this.translate.get("perms.set").subscribe(t => this.messenger.show(t));
})
}
public rejectRequest(wa: WorkerAccess) {
this.apiService.rejectWorkerAccessRequest(wa.worker.id, this.projectId)
.subscribe(() => this.getProjectAccesses())
.subscribe(() => {
this.getProjectAccesses();
this.translate.get("perms.set").subscribe(t => this.messenger.show(t));
})
}
private getProject() {
@ -64,7 +74,31 @@ export class ProjectPermsComponent implements OnInit {
})
}
private getProjectManagers() {
this.apiService.getManagerListWithRoleOn(this.projectId)
.subscribe(data => {
this.managerRoles = data["content"]["managers"].map(d =>
ManagerRoleOnProject.fromEntity(d))
})
}
public refresh() {
this.getProjectAccesses()
this.getProjectAccesses();
this.getProjectManagers();
}
public onSelectManager(manager: Manager) {
if (manager.id != this.auth.account.id) {
this.apiService.setManagerRoleOnProject(this.projectId, 1, manager.id)
.subscribe(() => this.refresh())
}
}
public onRoleChange(manager: ManagerRoleOnProject) {
this.apiService.setManagerRoleOnProject(this.projectId, manager.role, manager.manager.id)
.subscribe(() => {
this.refresh();
this.translate.get("perms.set").subscribe(t => this.messenger.show(t));
})
}
}

View File

@ -11,8 +11,7 @@
{{"project_select.none" | translate}}
</mat-option>
<mat-option *ngFor="let p of projectList" [value]="p">
<mat-icon *ngIf="p.public">public</mat-icon>
<mat-icon *ngIf="!p.public">lock</mat-icon>
<project-icon [project]="p"></project-icon>
<span style="width: 3em; display: inline-block">{{p.id}}</span>
{{p.name}}
</mat-option>

View File

@ -5,25 +5,37 @@
<mat-card-content>
<form (ngSubmit)="onSubmit()" *ngIf="project != undefined" id="uf">
<mat-form-field appearance="outline">
<input type="text" matInput [(ngModel)]="project.name" name="name" placeholder="Name">
<mat-label>{{"project.name" | translate}}</mat-label>
<input type="text" matInput [(ngModel)]="project.name" name="name"
[placeholder]="'project.name'|translate"
>
</mat-form-field>
<mat-form-field appearance="outline">
<textarea matInput [(ngModel)]="project.motd" placeholder="Message of the day"
<mat-label>{{"project.motd" | translate}}</mat-label>
<textarea matInput [(ngModel)]="project.motd"
[placeholder]="'project.motd'|translate"
name="motd"></textarea>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{"project.clone_url" | translate}}</mat-label>
<input type="text" matInput [(ngModel)]="project.clone_url" name="clone_url"
placeholder="Git clone url">
[placeholder]="'project.clone_url'|translate"
>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{"project.version" | translate}}</mat-label>
<input type="text" matInput [(ngModel)]="project.version" name="version"
[placeholder]="'project.version'|translate"
>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{"project.git_repo" | translate}}</mat-label>
<input type="text" matInput [(ngModel)]="project.git_repo" name="git_repo"
placeholder='Full repository name (e.g. "simon987/task_tracker")'>
<mat-hint align="start">Changes on the <strong>master</strong> branch will be tracked if webhooks
are
enabled
</mat-hint>
[placeholder]="'project.git_repo_placeholder'|translate"
>
<mat-hint align="start">{{'project.git_repo_hint'|translate}}</mat-hint>
</mat-form-field>
<project-select [(project)]="selectedProject"></project-select>
</form>

View File

@ -51,7 +51,7 @@
},
"project": {
"name": "Project name",
"clone_url": "Clone url",
"clone_url": "Git clone url",
"clone_url_placeholder": "Example: \"https://github.com/simon987/task_tracker\"",
"git_repo_placeholder": "Example: \"simon987/task_tracker\"",
"git_repo_hint": "Changes in the master branch will be tracked if webhooks are enabled",
@ -65,7 +65,9 @@
"motd": "Message of the day",
"update": "Edit",
"perms": "Permissions",
"chain": "Chain tasks to"
"chain": "Chain tasks to",
"manager_select": "Give access to manager",
"version": "Git version (commit hash)"
},
"dashboard": {
"title": "Dashboard for",
@ -107,7 +109,14 @@
"refresh": "Refresh",
"pending": "(Pending)",
"assign": "Assign",
"submit": "Submit"
"submit": "Submit",
"read": "Read",
"edit": "Edit",
"manage": "Manage permissions",
"set": "Changes saved",
"workers": "Workers",
"no_workers": "No workers have explicit access to this project",
"managers": "Managers"
},
"messenger": {
"close": "Close",

View File

@ -66,7 +66,9 @@
"motd": "Message du jour",
"update": "Mettre à jour",
"perms": "Permissions",
"chain": "Enchainer les tâches vers"
"chain": "Enchainer les tâches vers",
"manager_select": "Donner accès à",
"version": "Version git (hash du commit)"
},
"dashboard": {
"title": "Tableau de bord pour ",
@ -100,7 +102,7 @@
},
"perms": {
"title": "Permissions du projet",
"subtitle": "Les Workers doivent faire un requête d'acces pour pouvoir travailler sur les projets privés",
"subtitle": "Les Workers doivent faire une requête d'acces pour pouvoir travailler sur les projets privés",
"unauthorized": "Vous devez avoir ROLE_GESTION_ACCES sur ce project pour accéder à cette page",
"created": "Créé le",
"grant": "Accepter la requête",
@ -109,7 +111,14 @@
"refresh": "Refraichir",
"pending": "(En attente)",
"assign": "Assigner",
"submit": "Soumettre"
"submit": "Soumettre",
"read": "Lecture",
"edit": "Modification",
"manage": "Gestion des permissions",
"set": "Changements enregistrés",
"workers": "Workers",
"no_workers": "Aucun Worker n'a explicitement accès à ce projet",
"managers": "Managers"
},
"messenger": {
"close": "Fermer",

View File

@ -99,3 +99,12 @@ pre {
color: #ff4081;
}
.spacer {
flex: 1 1 auto;
}
.mat-list-item-content > mat-icon {
margin-right: 15px;
}