mirror of
https://github.com/hay-kot/mealie.git
synced 2025-08-22 14:33:33 -07:00
token CRUD
This commit is contained in:
parent
20f7c4e684
commit
3c051c93db
8 changed files with 282 additions and 17 deletions
|
@ -16,17 +16,10 @@ const usersURLs = {
|
|||
userID: id => `${userPrefix}/${id}`,
|
||||
password: id => `${userPrefix}/${id}/password`,
|
||||
resetPassword: id => `${userPrefix}/${id}/reset-password`,
|
||||
userAPICreate: `${userPrefix}/api-tokens`,
|
||||
userAPIDelete: id => `${userPrefix}/api-tokens/${id}`,
|
||||
};
|
||||
|
||||
function deleteErrorText(response) {
|
||||
switch (response.data.detail) {
|
||||
case "SUPER_USER":
|
||||
return i18n.t("user.error-cannot-delete-super-user");
|
||||
|
||||
default:
|
||||
return i18n.t("user.you-are-not-allowed-to-delete-this-user");
|
||||
}
|
||||
}
|
||||
export const userAPI = {
|
||||
async login(formData) {
|
||||
let response = await apiReq.post(authURLs.token, formData, null, function() {
|
||||
|
@ -90,4 +83,21 @@ export const userAPI = {
|
|||
() => i18n.t("user.password-has-been-reset-to-the-default-password")
|
||||
);
|
||||
},
|
||||
async createAPIToken(name) {
|
||||
const response = await apiReq.post(usersURLs.userAPICreate, { name });
|
||||
return response.data;
|
||||
},
|
||||
async deleteAPIToken(id) {
|
||||
const response = await apiReq.delete(usersURLs.userAPIDelete(id));
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
const deleteErrorText = response => {
|
||||
switch (response.data.detail) {
|
||||
case "SUPER_USER":
|
||||
return i18n.t("user.error-cannot-delete-super-user");
|
||||
default:
|
||||
return i18n.t("user.you-are-not-allowed-to-delete-this-user");
|
||||
}
|
||||
};
|
||||
|
|
147
frontend/src/pages/Admin/Profile/APITokenCard.vue
Normal file
147
frontend/src/pages/Admin/Profile/APITokenCard.vue
Normal file
|
@ -0,0 +1,147 @@
|
|||
<template>
|
||||
<StatCard icon="mdi-api" color="accent">
|
||||
<template v-slot:after-heading>
|
||||
<div class="ml-auto text-right">
|
||||
<div class="body-3 grey--text font-weight-light" v-text="'API Tokens'" />
|
||||
<h3 class="display-2 font-weight-light text--primary">
|
||||
<small> {{ user.tokens.length }} </small>
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:bottom>
|
||||
<v-subheader class="mb-n2">ACTIVE TOKENS</v-subheader>
|
||||
<v-virtual-scroll height="210" item-height="70" :items="user.tokens" class="mt-2">
|
||||
<template v-slot:default="{ item }">
|
||||
<v-divider></v-divider>
|
||||
<v-list-item @click.prevent>
|
||||
<v-list-item-avatar>
|
||||
<v-icon large dark color="accent">
|
||||
mdi-api
|
||||
</v-icon>
|
||||
</v-list-item-avatar>
|
||||
|
||||
<v-list-item-content>
|
||||
<v-list-item-title v-text="item.name"></v-list-item-title>
|
||||
</v-list-item-content>
|
||||
|
||||
<v-list-item-action class="ml-auto">
|
||||
<v-btn large icon @click.stop="deleteToken(item.id)">
|
||||
<v-icon color="accent">mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</v-list-item-action>
|
||||
</v-list-item>
|
||||
<v-divider></v-divider>
|
||||
</template>
|
||||
</v-virtual-scroll>
|
||||
|
||||
<v-divider></v-divider>
|
||||
<v-card-actions class="pb-1 pt-3">
|
||||
<v-spacer></v-spacer>
|
||||
<BaseDialog
|
||||
:title="'Create an API Token'"
|
||||
title-icon="mdi-api"
|
||||
@submit="createToken"
|
||||
:submit-text="buttonText"
|
||||
:loading="loading"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-form ref="newTokenForm">
|
||||
<v-text-field v-model="name" label="Token Name" required> </v-text-field>
|
||||
</v-form>
|
||||
|
||||
<div v-if="createdToken != ''">
|
||||
<v-textarea
|
||||
class="mb-0 pb-0"
|
||||
label="API Token"
|
||||
read
|
||||
v-model="createdToken"
|
||||
append-outer-icon="mdi-content-copy"
|
||||
@click:append-outer="copyToken"
|
||||
>
|
||||
</v-textarea>
|
||||
<v-subheader class="text-center">
|
||||
Copy this token for use with an external application. This token will not be viewable again.
|
||||
</v-subheader>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<template v-slot:open="{ open }">
|
||||
<v-btn color="success" @click="open">
|
||||
<v-icon left> mdi-plus </v-icon>
|
||||
{{ $t("general.create") }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</v-card-actions>
|
||||
</template>
|
||||
</StatCard>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BaseDialog from "@/components/UI/Dialogs/BaseDialog";
|
||||
import StatCard from "@/components/UI/StatCard";
|
||||
import { api } from "@/api";
|
||||
import { validators } from "@/mixins/validators";
|
||||
import { initials } from "@/mixins/initials";
|
||||
export default {
|
||||
components: {
|
||||
BaseDialog,
|
||||
StatCard,
|
||||
},
|
||||
mixins: [validators, initials],
|
||||
data() {
|
||||
return {
|
||||
name: "",
|
||||
loading: false,
|
||||
createdToken: "",
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.$store.dispatch("requestUserData");
|
||||
},
|
||||
|
||||
computed: {
|
||||
user() {
|
||||
return this.$store.getters.getUserData;
|
||||
},
|
||||
buttonText() {
|
||||
if (this.createdToken === "") {
|
||||
return "Create";
|
||||
} else {
|
||||
return "Close";
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async createToken() {
|
||||
if (this.loading === true) {
|
||||
this.loading = false;
|
||||
this.$store.dispatch("requestUserData");
|
||||
this.createdToken = "";
|
||||
this.name = "";
|
||||
return;
|
||||
}
|
||||
this.loading = true;
|
||||
if (this.$refs.newTokenForm.validate()) {
|
||||
const response = await api.users.createAPIToken(this.name);
|
||||
this.createdToken = response.token;
|
||||
}
|
||||
},
|
||||
async deleteToken(id) {
|
||||
await api.users.deleteAPIToken(id);
|
||||
this.$store.dispatch("requestUserData");
|
||||
},
|
||||
copyToken() {
|
||||
const copyText = this.createdToken;
|
||||
navigator.clipboard.writeText(copyText).then(
|
||||
() => console.log("Copied", copyText),
|
||||
() => console.log("Copied Failed", copyText)
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style></style>
|
|
@ -7,6 +7,7 @@
|
|||
</v-col>
|
||||
<v-col cols="12" sm="12" lg="6">
|
||||
<ProfileGroupCard />
|
||||
<APITokenCard class="mt-10" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row class="mt-7">
|
||||
|
@ -19,12 +20,14 @@
|
|||
<script>
|
||||
import ProfileThemeCard from "./ProfileThemeCard";
|
||||
import ProfileGroupCard from "./ProfileGroupCard";
|
||||
import APITokenCard from "./APITokenCard";
|
||||
import UserCard from "./UserCard";
|
||||
export default {
|
||||
components: {
|
||||
UserCard,
|
||||
ProfileThemeCard,
|
||||
ProfileGroupCard,
|
||||
APITokenCard,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -8,7 +8,7 @@ from mealie.db.models.recipe.recipe import Category, RecipeModel, Tag
|
|||
from mealie.db.models.settings import CustomPage, SiteSettings
|
||||
from mealie.db.models.sign_up import SignUp
|
||||
from mealie.db.models.theme import SiteThemeModel
|
||||
from mealie.db.models.users import User
|
||||
from mealie.db.models.users import LongLiveToken, User
|
||||
from mealie.schema.category import RecipeCategoryResponse, RecipeTagResponse
|
||||
from mealie.schema.events import Event as EventSchema
|
||||
from mealie.schema.meal import MealPlanInDB
|
||||
|
@ -17,7 +17,7 @@ from mealie.schema.settings import CustomPageOut
|
|||
from mealie.schema.settings import SiteSettings as SiteSettingsSchema
|
||||
from mealie.schema.sign_up import SignUpOut
|
||||
from mealie.schema.theme import SiteTheme
|
||||
from mealie.schema.user import GroupInDB, UserInDB
|
||||
from mealie.schema.user import GroupInDB, LongLiveTokenInDB, UserInDB
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
logger = getLogger()
|
||||
|
@ -106,6 +106,13 @@ class _Users(BaseDocument):
|
|||
return self.schema.from_orm(entry)
|
||||
|
||||
|
||||
class _LongLiveToken(BaseDocument):
|
||||
def __init__(self) -> None:
|
||||
self.primary_key = "id"
|
||||
self.sql_model = LongLiveToken
|
||||
self.schema = LongLiveTokenInDB
|
||||
|
||||
|
||||
class _Groups(BaseDocument):
|
||||
def __init__(self) -> None:
|
||||
self.primary_key = "id"
|
||||
|
@ -158,6 +165,7 @@ class Database:
|
|||
self.categories = _Categories()
|
||||
self.tags = _Tags()
|
||||
self.users = _Users()
|
||||
self.api_tokens = _LongLiveToken()
|
||||
self.sign_ups = _SignUps()
|
||||
self.groups = _Groups()
|
||||
self.custom_pages = _CustomPages()
|
||||
|
|
|
@ -3,11 +3,19 @@ from mealie.db.models.group import Group
|
|||
from mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
|
||||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
|
||||
|
||||
# I'm not sure this is necessasry, browser based settings may be sufficient
|
||||
# class UserSettings(SqlAlchemyBase, BaseMixins):
|
||||
# __tablename__ = "user_settings"
|
||||
# id = Column(Integer, primary_key=True, index=True)
|
||||
# parent_id = Column(String, ForeignKey("users.id"))
|
||||
|
||||
class LongLiveToken(SqlAlchemyBase, BaseMixins):
|
||||
__tablename__ = "long_live_tokens"
|
||||
id = Column(Integer, primary_key=True)
|
||||
parent_id = Column(Integer, ForeignKey("users.id"))
|
||||
name = Column(String)
|
||||
token = Column(String, unique=True, nullable=False)
|
||||
user = orm.relationship("User")
|
||||
|
||||
def __init__(self, session, name, token, parent_id) -> None:
|
||||
self.name = name
|
||||
self.token = token
|
||||
self.user = User.get_ref(session, parent_id)
|
||||
|
||||
|
||||
class User(SqlAlchemyBase, BaseMixins):
|
||||
|
@ -19,6 +27,9 @@ class User(SqlAlchemyBase, BaseMixins):
|
|||
group_id = Column(Integer, ForeignKey("groups.id"))
|
||||
group = orm.relationship("Group", back_populates="users")
|
||||
admin = Column(Boolean, default=False)
|
||||
tokens: list[LongLiveToken] = orm.relationship(
|
||||
LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
@ -49,3 +60,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
|||
|
||||
def update_password(self, password):
|
||||
self.password = password
|
||||
|
||||
@staticmethod
|
||||
def get_ref(session, id: str):
|
||||
return session.query(User).filter(User.id == id).one()
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from . import auth, crud, sign_up
|
||||
from . import api_tokens, auth, crud, sign_up
|
||||
|
||||
user_router = APIRouter()
|
||||
|
||||
user_router.include_router(auth.router)
|
||||
user_router.include_router(sign_up.router)
|
||||
user_router.include_router(crud.router)
|
||||
user_router.include_router(api_tokens.router)
|
||||
|
|
56
mealie/routes/users/api_tokens.py
Normal file
56
mealie/routes/users/api_tokens.py
Normal file
|
@ -0,0 +1,56 @@
|
|||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.param_functions import Depends
|
||||
from mealie.core.security import create_access_token
|
||||
from mealie.db.database import db
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.routes.deps import get_current_user
|
||||
from mealie.schema.user import CreateToken, LoingLiveTokenIn, LongLiveTokenInDB, UserInDB
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
router = APIRouter(prefix="/api/users", tags=["User API Tokens"])
|
||||
|
||||
|
||||
@router.post("/api-tokens")
|
||||
async def create_api_token(
|
||||
token_name: LoingLiveTokenIn,
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
""" Create api_token in the Database """
|
||||
|
||||
token_data = {"long_token": True, "user": current_user.email}
|
||||
|
||||
five_years = timedelta(1825)
|
||||
token = create_access_token(token_data, five_years)
|
||||
|
||||
token_model = CreateToken(
|
||||
name=token_name.name,
|
||||
token=token,
|
||||
parent_id=current_user.id,
|
||||
)
|
||||
|
||||
new_token_in_db = db.api_tokens.create(session, token_model)
|
||||
|
||||
if new_token_in_db:
|
||||
return {"token": token}
|
||||
|
||||
|
||||
@router.delete("/api-tokens/{token_id}")
|
||||
async def delete_api_token(
|
||||
token_id: int,
|
||||
current_user: UserInDB = Depends(get_current_user),
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
""" Delete api_token from the Database """
|
||||
token: LongLiveTokenInDB = db.api_tokens.get(session, token_id)
|
||||
|
||||
if not token:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, f"Could not locate token with id '{token_id}' in database")
|
||||
|
||||
if token.user.email == current_user.email:
|
||||
deleted_token = db.api_tokens.delete(session, token_id)
|
||||
return {"token_delete": deleted_token.name}
|
||||
else:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
|
|
@ -10,6 +10,22 @@ from pydantic.types import constr
|
|||
from pydantic.utils import GetterDict
|
||||
|
||||
|
||||
class LoingLiveTokenIn(CamelModel):
|
||||
name: str
|
||||
|
||||
|
||||
class LongLiveTokenOut(LoingLiveTokenIn):
|
||||
id: int
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
||||
|
||||
class CreateToken(LoingLiveTokenIn):
|
||||
parent_id: int
|
||||
token: str
|
||||
|
||||
|
||||
class ChangePassword(CamelModel):
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
@ -53,6 +69,7 @@ class UserIn(UserBase):
|
|||
class UserOut(UserBase):
|
||||
id: int
|
||||
group: str
|
||||
tokens: Optional[list[LongLiveTokenOut]]
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
@ -96,3 +113,11 @@ class GroupInDB(UpdateGroup):
|
|||
**GetterDict(orm_model),
|
||||
"webhook_urls": [x.url for x in orm_model.webhook_urls if x],
|
||||
}
|
||||
|
||||
|
||||
class LongLiveTokenInDB(LoingLiveTokenIn):
|
||||
id: int
|
||||
user: UserInDB
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue