token CRUD

This commit is contained in:
hay-kot 2021-05-06 10:52:01 -08:00
commit 3c051c93db
8 changed files with 282 additions and 17 deletions

View file

@ -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");
}
}; };

View 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>

View file

@ -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>

View file

@ -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()

View file

@ -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()

View file

@ -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)

View 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)

View file

@ -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