mirror of
https://github.com/hay-kot/mealie.git
synced 2025-08-22 22:43:34 -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}`,
|
userID: id => `${userPrefix}/${id}`,
|
||||||
password: id => `${userPrefix}/${id}/password`,
|
password: id => `${userPrefix}/${id}/password`,
|
||||||
resetPassword: id => `${userPrefix}/${id}/reset-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 = {
|
export const userAPI = {
|
||||||
async login(formData) {
|
async login(formData) {
|
||||||
let response = await apiReq.post(authURLs.token, formData, null, function() {
|
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")
|
() => 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>
|
||||||
<v-col cols="12" sm="12" lg="6">
|
<v-col cols="12" sm="12" lg="6">
|
||||||
<ProfileGroupCard />
|
<ProfileGroupCard />
|
||||||
|
<APITokenCard class="mt-10" />
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
<v-row class="mt-7">
|
<v-row class="mt-7">
|
||||||
|
@ -19,12 +20,14 @@
|
||||||
<script>
|
<script>
|
||||||
import ProfileThemeCard from "./ProfileThemeCard";
|
import ProfileThemeCard from "./ProfileThemeCard";
|
||||||
import ProfileGroupCard from "./ProfileGroupCard";
|
import ProfileGroupCard from "./ProfileGroupCard";
|
||||||
|
import APITokenCard from "./APITokenCard";
|
||||||
import UserCard from "./UserCard";
|
import UserCard from "./UserCard";
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
UserCard,
|
UserCard,
|
||||||
ProfileThemeCard,
|
ProfileThemeCard,
|
||||||
ProfileGroupCard,
|
ProfileGroupCard,
|
||||||
|
APITokenCard,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</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.settings import CustomPage, SiteSettings
|
||||||
from mealie.db.models.sign_up import SignUp
|
from mealie.db.models.sign_up import SignUp
|
||||||
from mealie.db.models.theme import SiteThemeModel
|
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.category import RecipeCategoryResponse, RecipeTagResponse
|
||||||
from mealie.schema.events import Event as EventSchema
|
from mealie.schema.events import Event as EventSchema
|
||||||
from mealie.schema.meal import MealPlanInDB
|
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.settings import SiteSettings as SiteSettingsSchema
|
||||||
from mealie.schema.sign_up import SignUpOut
|
from mealie.schema.sign_up import SignUpOut
|
||||||
from mealie.schema.theme import SiteTheme
|
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
|
from sqlalchemy.orm.session import Session
|
||||||
|
|
||||||
logger = getLogger()
|
logger = getLogger()
|
||||||
|
@ -106,6 +106,13 @@ class _Users(BaseDocument):
|
||||||
return self.schema.from_orm(entry)
|
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):
|
class _Groups(BaseDocument):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.primary_key = "id"
|
self.primary_key = "id"
|
||||||
|
@ -158,6 +165,7 @@ class Database:
|
||||||
self.categories = _Categories()
|
self.categories = _Categories()
|
||||||
self.tags = _Tags()
|
self.tags = _Tags()
|
||||||
self.users = _Users()
|
self.users = _Users()
|
||||||
|
self.api_tokens = _LongLiveToken()
|
||||||
self.sign_ups = _SignUps()
|
self.sign_ups = _SignUps()
|
||||||
self.groups = _Groups()
|
self.groups = _Groups()
|
||||||
self.custom_pages = _CustomPages()
|
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 mealie.db.models.model_base import BaseMixins, SqlAlchemyBase
|
||||||
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, orm
|
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):
|
class LongLiveToken(SqlAlchemyBase, BaseMixins):
|
||||||
# __tablename__ = "user_settings"
|
__tablename__ = "long_live_tokens"
|
||||||
# id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True)
|
||||||
# parent_id = Column(String, ForeignKey("users.id"))
|
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):
|
class User(SqlAlchemyBase, BaseMixins):
|
||||||
|
@ -19,6 +27,9 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||||
group_id = Column(Integer, ForeignKey("groups.id"))
|
group_id = Column(Integer, ForeignKey("groups.id"))
|
||||||
group = orm.relationship("Group", back_populates="users")
|
group = orm.relationship("Group", back_populates="users")
|
||||||
admin = Column(Boolean, default=False)
|
admin = Column(Boolean, default=False)
|
||||||
|
tokens: list[LongLiveToken] = orm.relationship(
|
||||||
|
LongLiveToken, back_populates="user", cascade="all, delete, delete-orphan", single_parent=True
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
@ -49,3 +60,7 @@ class User(SqlAlchemyBase, BaseMixins):
|
||||||
|
|
||||||
def update_password(self, password):
|
def update_password(self, password):
|
||||||
self.password = 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 fastapi import APIRouter
|
||||||
|
|
||||||
from . import auth, crud, sign_up
|
from . import api_tokens, auth, crud, sign_up
|
||||||
|
|
||||||
user_router = APIRouter()
|
user_router = APIRouter()
|
||||||
|
|
||||||
user_router.include_router(auth.router)
|
user_router.include_router(auth.router)
|
||||||
user_router.include_router(sign_up.router)
|
user_router.include_router(sign_up.router)
|
||||||
user_router.include_router(crud.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
|
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):
|
class ChangePassword(CamelModel):
|
||||||
current_password: str
|
current_password: str
|
||||||
new_password: str
|
new_password: str
|
||||||
|
@ -53,6 +69,7 @@ class UserIn(UserBase):
|
||||||
class UserOut(UserBase):
|
class UserOut(UserBase):
|
||||||
id: int
|
id: int
|
||||||
group: str
|
group: str
|
||||||
|
tokens: Optional[list[LongLiveTokenOut]]
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
orm_mode = True
|
orm_mode = True
|
||||||
|
@ -96,3 +113,11 @@ class GroupInDB(UpdateGroup):
|
||||||
**GetterDict(orm_model),
|
**GetterDict(orm_model),
|
||||||
"webhook_urls": [x.url for x in orm_model.webhook_urls if x],
|
"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