Support token auth

This commit is contained in:
cuigh 2021-12-24 17:24:09 +08:00
parent 752ddff01f
commit 16888e54ee
17 changed files with 190 additions and 60 deletions

View File

@ -3,6 +3,7 @@ package biz
import ( import (
"context" "context"
"github.com/cuigh/auxo/data"
"github.com/cuigh/auxo/errors" "github.com/cuigh/auxo/errors"
"github.com/cuigh/auxo/net/web" "github.com/cuigh/auxo/net/web"
"github.com/cuigh/auxo/security/passwd" "github.com/cuigh/auxo/security/passwd"
@ -30,6 +31,7 @@ type UserBiz interface {
Update(user *dao.User, ctxUser web.User) (err error) Update(user *dao.User, ctxUser web.User) (err error)
FindByID(id string) (user *dao.User, err error) FindByID(id string) (user *dao.User, err error)
FindByName(loginName string) (user *dao.User, err error) FindByName(loginName string) (user *dao.User, err error)
FindByToken(token string) (user *dao.User, err error)
FindPrivacy(loginName string) (privacy *UserPrivacy, err error) FindPrivacy(loginName string) (privacy *UserPrivacy, err error)
Count() (count int, err error) Count() (count int, err error)
Delete(id, name string, user web.User) (err error) Delete(id, name string, user web.User) (err error)
@ -76,6 +78,10 @@ func (b *userBiz) FindByName(loginName string) (user *dao.User, err error) {
return b.d.UserGetByName(context.TODO(), loginName) return b.d.UserGetByName(context.TODO(), loginName)
} }
func (b *userBiz) FindByToken(token string) (user *dao.User, err error) {
return b.d.UserGetByToken(context.TODO(), token)
}
func (b *userBiz) FindPrivacy(loginName string) (privacy *UserPrivacy, err error) { func (b *userBiz) FindPrivacy(loginName string) (privacy *UserPrivacy, err error) {
var u *dao.User var u *dao.User
u, err = b.d.UserGetByName(context.TODO(), loginName) u, err = b.d.UserGetByName(context.TODO(), loginName)
@ -93,6 +99,7 @@ func (b *userBiz) FindPrivacy(loginName string) (privacy *UserPrivacy, err error
} }
func (b *userBiz) Create(user *dao.User, ctxUser web.User) (id string, err error) { func (b *userBiz) Create(user *dao.User, ctxUser web.User) (id string, err error) {
user.Tokens = data.Options{data.Option{Name: "test", Value: "abc123"}}
user.ID = createId() user.ID = createId()
user.Status = UserStatusActive user.Status = UserStatusActive
user.CreatedAt = now() user.CreatedAt = now()

View File

@ -26,6 +26,7 @@ func (d *Dao) UserUpdate(ctx context.Context, user *dao.User) (err error) {
old.Admin = user.Admin old.Admin = user.Admin
old.Type = user.Type old.Type = user.Type
old.Roles = user.Roles old.Roles = user.Roles
old.Tokens = user.Tokens
old.UpdatedAt = user.UpdatedAt old.UpdatedAt = user.UpdatedAt
old.UpdatedBy = user.UpdatedBy old.UpdatedBy = user.UpdatedBy
return old return old
@ -101,12 +102,29 @@ func (d *Dao) UserGetByName(ctx context.Context, loginName string) (user *dao.Us
return nil, err return nil, err
} }
func (d *Dao) UserGetByToken(ctx context.Context, token string) (user *dao.User, err error) {
u := &dao.User{}
found, err := d.find(User, u, func() bool {
for _, t := range u.Tokens {
if t.Value == token {
return true
}
}
return false
})
if found {
return u, nil
}
return nil, err
}
func (d *Dao) UserUpdateProfile(ctx context.Context, user *dao.User) (err error) { func (d *Dao) UserUpdateProfile(ctx context.Context, user *dao.User) (err error) {
old := &dao.User{} old := &dao.User{}
return d.update(User, user.ID, old, func() interface{} { return d.update(User, user.ID, old, func() interface{} {
old.Name = user.Name old.Name = user.Name
old.LoginName = user.LoginName old.LoginName = user.LoginName
old.Email = user.Email old.Email = user.Email
old.Tokens = user.Tokens
old.UpdatedAt = user.UpdatedAt old.UpdatedAt = user.UpdatedAt
old.UpdatedBy = user.UpdatedBy old.UpdatedBy = user.UpdatedBy
return old return old

View File

@ -30,6 +30,7 @@ type Interface interface {
UserGet(ctx context.Context, id string) (*User, error) UserGet(ctx context.Context, id string) (*User, error)
UserGetByName(ctx context.Context, loginName string) (*User, error) UserGetByName(ctx context.Context, loginName string) (*User, error)
UserGetByToken(ctx context.Context, token string) (user *User, err error)
UserSearch(ctx context.Context, args *UserSearchArgs) (users []*User, count int, err error) UserSearch(ctx context.Context, args *UserSearchArgs) (users []*User, count int, err error)
UserCount(ctx context.Context) (int, error) UserCount(ctx context.Context) (int, error)
UserCreate(ctx context.Context, user *User) error UserCreate(ctx context.Context, user *User) error

View File

@ -48,7 +48,7 @@ func (t Time) String() string {
} }
type Operator struct { type Operator struct {
ID string `json:"id,omitempty" bson:"_id,omitempty"` ID string `json:"id,omitempty" bson:"id,omitempty"`
Name string `json:"name,omitempty" bson:"name,omitempty"` Name string `json:"name,omitempty" bson:"name,omitempty"`
} }
@ -72,20 +72,21 @@ type Role struct {
} }
type User struct { type User struct {
ID string `json:"id,omitempty" bson:"_id"` ID string `json:"id,omitempty" bson:"_id"`
Name string `json:"name" bson:"name" valid:"required"` Name string `json:"name" bson:"name" valid:"required"`
LoginName string `json:"loginName" bson:"login_name" valid:"required"` LoginName string `json:"loginName" bson:"login_name" valid:"required"`
Password string `json:"-" bson:"password"` Password string `json:"-" bson:"password"`
Salt string `json:"-" bson:"salt"` Salt string `json:"-" bson:"salt"`
Email string `json:"email" bson:"email" valid:"required"` Email string `json:"email" bson:"email" valid:"required"`
Admin bool `json:"admin" bson:"admin"` Admin bool `json:"admin" bson:"admin"`
Type string `json:"type" bson:"type"` Type string `json:"type" bson:"type"`
Status int32 `json:"status" bson:"status"` Status int32 `json:"status" bson:"status"`
Roles []string `json:"roles,omitempty" bson:"roles,omitempty"` Roles []string `json:"roles,omitempty" bson:"roles,omitempty"`
CreatedAt Time `json:"createdAt" bson:"created_at"` Tokens data.Options `json:"tokens,omitempty" bson:"tokens,omitempty"`
UpdatedAt Time `json:"updatedAt" bson:"updated_at"` CreatedAt Time `json:"createdAt" bson:"created_at"`
CreatedBy Operator `json:"createdBy" bson:"created_by"` UpdatedAt Time `json:"updatedAt" bson:"updated_at"`
UpdatedBy Operator `json:"updatedBy" bson:"updated_by"` CreatedBy Operator `json:"createdBy" bson:"created_by"`
UpdatedBy Operator `json:"updatedBy" bson:"updated_by"`
} }
type UserSearchArgs struct { type UserSearchArgs struct {

View File

@ -24,6 +24,7 @@ var indexes = map[string][]mongo.IndexModel{
Options: options.Index().SetUnique(true), Options: options.Index().SetUnique(true),
}, },
mongo.IndexModel{Keys: bson.D{{"name", 1}}}, mongo.IndexModel{Keys: bson.D{{"name", 1}}},
mongo.IndexModel{Keys: bson.D{{"tokens.value", 1}}},
}, },
"role": { "role": {
mongo.IndexModel{ mongo.IndexModel{

View File

@ -28,6 +28,7 @@ func (d *Dao) UserUpdate(ctx context.Context, user *dao.User) (err error) {
"admin": user.Admin, "admin": user.Admin,
"type": user.Type, "type": user.Type,
"roles": user.Roles, "roles": user.Roles,
"tokens": user.Tokens,
"updated_at": user.UpdatedAt, "updated_at": user.UpdatedAt,
"updated_by": user.UpdatedBy, "updated_by": user.UpdatedBy,
}, },
@ -90,12 +91,24 @@ func (d *Dao) UserGetByName(ctx context.Context, loginName string) (user *dao.Us
return return
} }
func (d *Dao) UserGetByToken(ctx context.Context, token string) (user *dao.User, err error) {
user = &dao.User{}
err = d.db.Collection(User).FindOne(ctx, bson.M{"tokens.value": token}).Decode(user)
if err == mongo.ErrNoDocuments {
return nil, nil
} else if err != nil {
return nil, err
}
return
}
func (d *Dao) UserUpdateProfile(ctx context.Context, user *dao.User) (err error) { func (d *Dao) UserUpdateProfile(ctx context.Context, user *dao.User) (err error) {
update := bson.M{ update := bson.M{
"$set": bson.M{ "$set": bson.M{
"name": user.Name, "name": user.Name,
"login_name": user.LoginName, "login_name": user.LoginName,
"email": user.Email, "email": user.Email,
"tokens": user.Tokens,
"updated_at": user.UpdatedAt, "updated_at": user.UpdatedAt,
"updated_by": user.UpdatedBy, "updated_by": user.UpdatedBy,
}, },

View File

@ -32,7 +32,7 @@ var (
func main() { func main() {
app.Name = "Swirl" app.Name = "Swirl"
app.Version = "1.0.0beta7" app.Version = "1.0.0rc1"
app.Desc = "A web management UI for Docker, focused on swarm cluster" app.Desc = "A web management UI for Docker, focused on swarm cluster"
app.Action = func(ctx *app.Context) error { app.Action = func(ctx *app.Context) error {
return run.Pipeline(misc.LoadOptions, initSystem, scaler.Start, startServer) return run.Pipeline(misc.LoadOptions, initSystem, scaler.Start, startServer)

View File

@ -42,7 +42,12 @@ func (c *Identifier) Apply(next web.HandlerFunc) web.HandlerFunc {
return func(ctx web.Context) error { return func(ctx web.Context) error {
token := c.extractToken(ctx) token := c.extractToken(ctx)
if token != "" { if token != "" {
user := c.identifyUser(token) var user web.User
if len(token) == 24 {
user = c.identifyBySession(token)
} else {
user = c.identifyByToken(token)
}
ctx.SetUser(user) ctx.SetUser(user)
} }
return next(ctx) return next(ctx)
@ -105,7 +110,7 @@ func (c *Identifier) extractToken(ctx web.Context) (token string) {
return return
} }
func (c *Identifier) identifyUser(token string) web.User { func (c *Identifier) identifyBySession(token string) web.User {
session, err := c.sb.Find(token) session, err := c.sb.Find(token)
if err != nil { if err != nil {
c.logger.Error("failed to find session: ", err) c.logger.Error("failed to find session: ", err)
@ -126,6 +131,30 @@ func (c *Identifier) identifyUser(token string) web.User {
return c.createUser(session) return c.createUser(session)
} }
func (c *Identifier) identifyByToken(token string) web.User {
u, err := c.ub.FindByToken(token)
if err != nil {
c.logger.Errorf("failed to find user by token '%s': %s", token, err)
return nil
} else if u == nil {
return nil
}
perms, err := c.rb.GetPerms(u.Roles)
if err != nil {
c.logger.Error("failed to load perms: ", err)
return nil
}
return &User{
token: token,
id: u.ID,
name: u.Name,
admin: u.Admin,
perm: NewPermMap(perms),
}
}
func (c *Identifier) createUser(s *dao.Session) web.User { func (c *Identifier) createUser(s *dao.Session) web.User {
return &User{ return &User{
token: s.ID, token: s.ID,

View File

@ -50,7 +50,7 @@ var Perms = map[string][]string{
"registry": {"view", "edit", "delete"}, "registry": {"view", "edit", "delete"},
"node": {"view", "edit", "delete"}, "node": {"view", "edit", "delete"},
"network": {"view", "edit", "delete", "disconnect"}, "network": {"view", "edit", "delete", "disconnect"},
"service": {"view", "edit", "delete", "restart", "rollback", "logs"}, "service": {"view", "edit", "delete", "deploy", "restart", "rollback", "logs"},
"task": {"view", "logs"}, "task": {"view", "logs"},
"stack": {"view", "edit", "delete", "deploy", "shutdown"}, "stack": {"view", "edit", "delete", "deploy", "shutdown"},
"config": {"view", "edit", "delete"}, "config": {"view", "edit", "delete"},

View File

@ -17,6 +17,10 @@ export interface User {
status: number; status: number;
email: string; email: string;
roles: string[]; roles: string[];
tokens: {
name: string;
value: string;
}[];
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
createdBy: { createdBy: {

View File

@ -29,6 +29,7 @@ export default {
"update": "Update", "update": "Update",
"submit": "Submit", "submit": "Submit",
"home": "Return to home", "home": "Return to home",
"copy": "Copy",
}, },
"perms": { "perms": {
"view": "View", "view": "View",
@ -182,6 +183,7 @@ export default {
"admins": "Admins", "admins": "Admins",
"active": "Active", "active": "Active",
"blocked": "Blocked", "blocked": "Blocked",
"tokens": "Tokens",
"perms": "Permissions", "perms": "Permissions",
"margin": "Margin", "margin": "Margin",
"metrics": "Metrics", "metrics": "Metrics",
@ -372,6 +374,7 @@ export default {
"profile": "User personal information", "profile": "User personal information",
"password": "User login password", "password": "User login password",
"preference": "User personalization", "preference": "User personalization",
"copied": "Copied",
"required_rule": "Cannot be empty", "required_rule": "Cannot be empty",
"email_rule": "Incorrect email format", "email_rule": "Incorrect email format",
"length_rule": "The length must be {min}-{max} bits", "length_rule": "The length must be {min}-{max} bits",

View File

@ -29,6 +29,7 @@ export default {
"update": "更新", "update": "更新",
"submit": "提交", "submit": "提交",
"home": "返回首页", "home": "返回首页",
"copy": "复制",
}, },
"perms": { "perms": {
"view": "浏览", "view": "浏览",
@ -182,6 +183,7 @@ export default {
"admins": "@:fields.admin", "admins": "@:fields.admin",
"active": "正常", "active": "正常",
"blocked": "屏蔽", "blocked": "屏蔽",
"tokens": "凭证",
"perms": "权限", "perms": "权限",
"margin": "边距", "margin": "边距",
"metrics": "指标", "metrics": "指标",
@ -372,6 +374,7 @@ export default {
"profile": "用户个人资料", "profile": "用户个人资料",
"password": "用户登录密码", "password": "用户登录密码",
"preference": "用户个性化设置", "preference": "用户个性化设置",
"copied": "已复制",
"required_rule": "不能为空", "required_rule": "不能为空",
"email_rule": "电子邮箱格式不正确", "email_rule": "电子邮箱格式不正确",
"length_rule": "长度必须为 {min}-{max} 位", "length_rule": "长度必须为 {min}-{max} 位",

View File

@ -28,6 +28,38 @@
<n-form-item-gi :label="t('fields.email')" path="email"> <n-form-item-gi :label="t('fields.email')" path="email">
<n-input :placeholder="t('fields.email')" v-model:value="profile.email" /> <n-input :placeholder="t('fields.email')" v-model:value="profile.email" />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi :label="t('fields.tokens')" path="tokens" span="2">
<n-dynamic-input
v-model:value="profile.tokens"
#="{ index, value }"
:on-create="() => ({ name: '', value: guid() })"
>
<n-input
:placeholder="t('fields.name')"
v-model:value="value.name"
style="width: 300px"
/>
<div style="height: 34px; line-height: 34px; margin: 0 8px">=</div>
<n-input-group>
<n-input :placeholder="t('fields.value')" v-model:value="value.value" readonly></n-input>
<n-tooltip trigger="hover">
<template #trigger>
<n-button
type="default"
#icon
@click="() => copy(value.value)"
v-if="isSupported"
>
<n-icon>
<copy-icon />
</n-icon>
</n-button>
</template>
{{ t(copied ? 'tips.copied' : 'buttons.copy') }}
</n-tooltip>
</n-input-group>
</n-dynamic-input>
</n-form-item-gi>
</n-grid> </n-grid>
</n-form> </n-form>
<n-button <n-button
@ -153,6 +185,7 @@ import {
NButton, NButton,
NSpace, NSpace,
NInput, NInput,
NInputGroup,
NIcon, NIcon,
NForm, NForm,
NFormItem, NFormItem,
@ -161,9 +194,12 @@ import {
NRadioButton, NRadioButton,
NRadioGroup, NRadioGroup,
NAlert, NAlert,
NDynamicInput,
NTooltip,
} from "naive-ui"; } from "naive-ui";
import { import {
SaveOutline as SaveIcon, SaveOutline as SaveIcon,
CopyOutline as CopyIcon,
} from "@vicons/ionicons5"; } from "@vicons/ionicons5";
import XPageHeader from "@/components/PageHeader.vue"; import XPageHeader from "@/components/PageHeader.vue";
import XPanel from "@/components/Panel.vue"; import XPanel from "@/components/Panel.vue";
@ -173,6 +209,8 @@ import { useForm, emailRule, requiredRule, customRule, lengthRule } from "@/util
import { Mutations } from "@/store/mutations"; import { Mutations } from "@/store/mutations";
import { useStore } from "vuex"; import { useStore } from "vuex";
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useClipboard } from '@vueuse/core'
import { guid } from "@/utils";
const { t } = useI18n() const { t } = useI18n()
const panel = ref('') const panel = ref('')
@ -193,6 +231,7 @@ const profileRules: any = {
}; };
const profileForm = ref(); const profileForm = ref();
const { submit: modifyProfile, submiting: profileSubmiting } = useForm(profileForm, () => userApi.modifyProfile(profile.value)) const { submit: modifyProfile, submiting: profileSubmiting } = useForm(profileForm, () => userApi.modifyProfile(profile.value))
const { copy, copied, isSupported } = useClipboard()
// password // password
const password = reactive({ const password = reactive({

View File

@ -1,39 +1,6 @@
<template> <template>
<x-page-header /> <x-page-header />
<n-space class="page-body" vertical :size="12"> <n-space class="page-body" vertical :size="12">
<x-panel title="Deployment" divider="bottom" :collapsed="panel !== 'deploy'" v-if="false">
<template #action>
<n-button
secondary
strong
class="toggle"
size="small"
@click="togglePanel('deploy')"
>{{ panel === 'deploy' ? t('buttons.collapse') : t('buttons.expand') }}</n-button>
</template>
<div style="padding: 4px 0 0 12px">
<n-form :model="setting" ref="formDeploy" :show-feedback="false">
<n-form-item :label="t('fields.keys')" path="deploy.keys">
<n-dynamic-input
v-model:value="setting.deploy.keys"
#="{ index, value }"
:on-create="newKey"
>
<n-input-group>
<n-input :placeholder="t('fields.name')" v-model:value="value.name" />
<n-input :placeholder="t('fields.token')" v-model:value="value.token" />
<n-date-picker :placeholder="t('fields.expiry')" v-model:value="value.expiry" type="date" clearable style="min-width: 200px"/>
</n-input-group>
</n-dynamic-input>
</n-form-item>
</n-form>
<n-button
type="primary"
style="margin-top: 12px"
@click="() => save('deploy', setting.deploy)"
>{{ t('buttons.save') }}</n-button>
</div>
</x-panel>
<x-panel title="LDAP" :subtitle="t('tips.ldap')" divider="bottom" :collapsed="panel !== 'ldap'"> <x-panel title="LDAP" :subtitle="t('tips.ldap')" divider="bottom" :collapsed="panel !== 'ldap'">
<template #action> <template #action>
<n-button <n-button
@ -181,10 +148,8 @@ import {
NFormItemGi, NFormItemGi,
NRadioGroup, NRadioGroup,
NRadio, NRadio,
NDynamicInput,
NSwitch, NSwitch,
NAlert, NAlert,
NDatePicker,
} from "naive-ui"; } from "naive-ui";
import XPageHeader from "@/components/PageHeader.vue"; import XPageHeader from "@/components/PageHeader.vue";
import XPanel from "@/components/Panel.vue"; import XPanel from "@/components/Panel.vue";
@ -211,10 +176,6 @@ function togglePanel(name: string) {
} }
} }
function newKey() {
return { name: '', token: '', expiry: undefined }
}
async function save(id: string, options: any) { async function save(id: string, options: any) {
await settingApi.save(id, options) await settingApi.save(id, options)
window.message.info(t('texts.action_success')); window.message.info(t('texts.action_success'));

View File

@ -71,6 +71,38 @@
</n-space> </n-space>
</n-checkbox-group> </n-checkbox-group>
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi :label="t('fields.tokens', 2)" span="2" path="tokens">
<n-dynamic-input
v-model:value="user.tokens"
#="{ index, value }"
:on-create="() => ({ name: '', value: guid() })"
>
<n-input
:placeholder="t('fields.name')"
v-model:value="value.name"
style="width: 300px"
/>
<div style="height: 34px; line-height: 34px; margin: 0 8px">=</div>
<n-input-group>
<n-input :placeholder="t('fields.value')" v-model:value="value.value" readonly></n-input>
<n-tooltip trigger="hover">
<template #trigger>
<n-button
type="default"
#icon
@click="() => copy(value.value)"
v-if="isSupported"
>
<n-icon>
<copy-icon />
</n-icon>
</n-button>
</template>
{{ t(copied ? 'tips.copied' : 'buttons.copy') }}
</n-tooltip>
</n-input-group>
</n-dynamic-input>
</n-form-item-gi>
<n-gi :span="2"> <n-gi :span="2">
<n-button <n-button
:disabled="submiting" :disabled="submiting"
@ -97,6 +129,7 @@ import {
NButton, NButton,
NSpace, NSpace,
NInput, NInput,
NInputGroup,
NIcon, NIcon,
NForm, NForm,
NGrid, NGrid,
@ -107,10 +140,13 @@ import {
NCheckbox, NCheckbox,
NRadioGroup, NRadioGroup,
NRadio, NRadio,
NDynamicInput,
NTooltip,
} from "naive-ui"; } from "naive-ui";
import { import {
ArrowBackCircleOutline as BackIcon, ArrowBackCircleOutline as BackIcon,
SaveOutline as SaveIcon, SaveOutline as SaveIcon,
CopyOutline as CopyIcon,
} from "@vicons/ionicons5"; } from "@vicons/ionicons5";
import XPageHeader from "@/components/PageHeader.vue"; import XPageHeader from "@/components/PageHeader.vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
@ -119,8 +155,10 @@ import userApi from "@/api/user";
import roleApi from "@/api/role"; import roleApi from "@/api/role";
import type { User } from "@/api/user"; import type { User } from "@/api/user";
import type { Role } from "@/api/role"; import type { Role } from "@/api/role";
import { useForm, emailRule, requiredRule } from "@/utils/form"; import { useForm, emailRule, requiredRule, customRule } from "@/utils/form";
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useClipboard } from '@vueuse/core'
import { guid } from "@/utils";
const { t } = useI18n() const { t } = useI18n()
const route = useRoute(); const route = useRoute();
@ -132,12 +170,16 @@ const rules: any = {
email: [requiredRule(), emailRule()], email: [requiredRule(), emailRule()],
password: requiredRule(), password: requiredRule(),
passwordConfirm: requiredRule(), passwordConfirm: requiredRule(),
tokens: customRule((rule: any, value: any[]) => {
return value?.every(v => v.name && v.value)
}, t('tips.required_rule')),
}; };
const form = ref(); const form = ref();
const { submit, submiting } = useForm(form, () => userApi.save(user.value), () => { const { submit, submiting } = useForm(form, () => userApi.save(user.value), () => {
window.message.info(t('texts.action_success')); window.message.info(t('texts.action_success'));
router.push({ name: 'user_list' }) router.push({ name: 'user_list' })
}) })
const { copy, copied, isSupported } = useClipboard()
async function fetchData() { async function fetchData() {
const id = route.params.id as string || '' const id = route.params.id as string || ''

View File

@ -46,4 +46,12 @@ export function isEmpty(...arrs: (any[] | undefined)[]): boolean {
export function toTitle(s: string): string { export function toTitle(s: string): string {
return s ? s[0].toUpperCase() + s.substring(1) : s return s ? s[0].toUpperCase() + s.substring(1) : s
}
export function guid() {
return s4() + s4() + s4() + s4() + s4() + s4() + s4() + s4()
}
function s4() {
return (((1 + Math.random()) * 0x10000) | 0).toString(16).substring(1);
} }

View File

@ -13,7 +13,7 @@ export const perms = [
}, },
{ {
key: 'service', key: 'service',
actions: ['view', 'edit', 'delete', 'restart', 'rollback', 'logs'], actions: ['view', 'edit', 'delete', 'deploy', 'restart', 'rollback', 'logs'],
}, },
{ {
key: 'task', key: 'task',