forked from open-webui/open-webui
		
	feat: jwt utils
This commit is contained in:
		
							parent
							
								
									f824bcc86e
								
							
						
					
					
						commit
						275523e32e
					
				
					 7 changed files with 273 additions and 7 deletions
				
			
		|  | @ -26,6 +26,8 @@ app = FastAPI() | ||||||
| origins = ["*"] | origins = ["*"] | ||||||
| 
 | 
 | ||||||
| app.state.ENABLE_SIGNUP = ENABLE_SIGNUP | app.state.ENABLE_SIGNUP = ENABLE_SIGNUP | ||||||
|  | app.state.JWT_EXPIRES_IN = "-1" | ||||||
|  | 
 | ||||||
| app.state.DEFAULT_MODELS = DEFAULT_MODELS | app.state.DEFAULT_MODELS = DEFAULT_MODELS | ||||||
| app.state.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS | app.state.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS | ||||||
| app.state.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE | app.state.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE | ||||||
|  |  | ||||||
|  | @ -7,6 +7,7 @@ from fastapi import APIRouter, status | ||||||
| from pydantic import BaseModel | from pydantic import BaseModel | ||||||
| import time | import time | ||||||
| import uuid | import uuid | ||||||
|  | import re | ||||||
| 
 | 
 | ||||||
| from apps.web.models.auths import ( | from apps.web.models.auths import ( | ||||||
|     SigninForm, |     SigninForm, | ||||||
|  | @ -25,7 +26,7 @@ from utils.utils import ( | ||||||
|     get_admin_user, |     get_admin_user, | ||||||
|     create_token, |     create_token, | ||||||
| ) | ) | ||||||
| from utils.misc import get_gravatar_url, validate_email_format | from utils.misc import parse_duration, validate_email_format | ||||||
| from constants import ERROR_MESSAGES | from constants import ERROR_MESSAGES | ||||||
| 
 | 
 | ||||||
| router = APIRouter() | router = APIRouter() | ||||||
|  | @ -95,10 +96,13 @@ async def update_password( | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @router.post("/signin", response_model=SigninResponse) | @router.post("/signin", response_model=SigninResponse) | ||||||
| async def signin(form_data: SigninForm): | async def signin(request: Request, form_data: SigninForm): | ||||||
|     user = Auths.authenticate_user(form_data.email.lower(), form_data.password) |     user = Auths.authenticate_user(form_data.email.lower(), form_data.password) | ||||||
|     if user: |     if user: | ||||||
|         token = create_token(data={"id": user.id}) |         token = create_token( | ||||||
|  |             data={"id": user.id}, | ||||||
|  |             expires_delta=parse_duration(request.app.state.JWT_EXPIRES_IN), | ||||||
|  |         ) | ||||||
| 
 | 
 | ||||||
|         return { |         return { | ||||||
|             "token": token, |             "token": token, | ||||||
|  | @ -145,7 +149,10 @@ async def signup(request: Request, form_data: SignupForm): | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         if user: |         if user: | ||||||
|             token = create_token(data={"id": user.id}) |             token = create_token( | ||||||
|  |                 data={"id": user.id}, | ||||||
|  |                 expires_delta=parse_duration(request.app.state.JWT_EXPIRES_IN), | ||||||
|  |             ) | ||||||
|             # response.set_cookie(key='token', value=token, httponly=True) |             # response.set_cookie(key='token', value=token, httponly=True) | ||||||
| 
 | 
 | ||||||
|             return { |             return { | ||||||
|  | @ -200,3 +207,33 @@ async def update_default_user_role( | ||||||
|     if form_data.role in ["pending", "user", "admin"]: |     if form_data.role in ["pending", "user", "admin"]: | ||||||
|         request.app.state.DEFAULT_USER_ROLE = form_data.role |         request.app.state.DEFAULT_USER_ROLE = form_data.role | ||||||
|     return request.app.state.DEFAULT_USER_ROLE |     return request.app.state.DEFAULT_USER_ROLE | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | ############################ | ||||||
|  | # JWT Expiration | ||||||
|  | ############################ | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @router.get("/token/expires") | ||||||
|  | async def get_token_expires_duration(request: Request, user=Depends(get_admin_user)): | ||||||
|  |     return request.app.state.JWT_EXPIRES_IN | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class UpdateJWTExpiresDurationForm(BaseModel): | ||||||
|  |     duration: str | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | @router.post("/token/expires/update") | ||||||
|  | async def update_token_expires_duration( | ||||||
|  |     request: Request, | ||||||
|  |     form_data: UpdateJWTExpiresDurationForm, | ||||||
|  |     user=Depends(get_admin_user), | ||||||
|  | ): | ||||||
|  |     pattern = r"^(-1|0|(-?\d+(\.\d+)?)(ms|s|m|h|d|w))$" | ||||||
|  | 
 | ||||||
|  |     # Check if the input string matches the pattern | ||||||
|  |     if re.match(pattern, form_data.duration): | ||||||
|  |         request.app.state.JWT_EXPIRES_IN = form_data.duration | ||||||
|  |         return request.app.state.JWT_EXPIRES_IN | ||||||
|  |     else: | ||||||
|  |         return request.app.state.JWT_EXPIRES_IN | ||||||
|  |  | ||||||
|  | @ -1,6 +1,8 @@ | ||||||
| from pathlib import Path | from pathlib import Path | ||||||
| import hashlib | import hashlib | ||||||
| import re | import re | ||||||
|  | from datetime import timedelta | ||||||
|  | from typing import Optional | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_gravatar_url(email): | def get_gravatar_url(email): | ||||||
|  | @ -76,3 +78,34 @@ def extract_folders_after_data_docs(path): | ||||||
|         tags.append("/".join(folders[: idx + 1])) |         tags.append("/".join(folders[: idx + 1])) | ||||||
| 
 | 
 | ||||||
|     return tags |     return tags | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | def parse_duration(duration: str) -> Optional[timedelta]: | ||||||
|  |     if duration == "-1" or duration == "0": | ||||||
|  |         return None | ||||||
|  | 
 | ||||||
|  |     # Regular expression to find number and unit pairs | ||||||
|  |     pattern = r"(-?\d+(\.\d+)?)(ms|s|m|h|d|w)" | ||||||
|  |     matches = re.findall(pattern, duration) | ||||||
|  | 
 | ||||||
|  |     if not matches: | ||||||
|  |         raise ValueError("Invalid duration string") | ||||||
|  | 
 | ||||||
|  |     total_duration = timedelta() | ||||||
|  | 
 | ||||||
|  |     for number, _, unit in matches: | ||||||
|  |         number = float(number) | ||||||
|  |         if unit == "ms": | ||||||
|  |             total_duration += timedelta(milliseconds=number) | ||||||
|  |         elif unit == "s": | ||||||
|  |             total_duration += timedelta(seconds=number) | ||||||
|  |         elif unit == "m": | ||||||
|  |             total_duration += timedelta(minutes=number) | ||||||
|  |         elif unit == "h": | ||||||
|  |             total_duration += timedelta(hours=number) | ||||||
|  |         elif unit == "d": | ||||||
|  |             total_duration += timedelta(days=number) | ||||||
|  |         elif unit == "w": | ||||||
|  |             total_duration += timedelta(weeks=number) | ||||||
|  | 
 | ||||||
|  |     return total_duration | ||||||
|  |  | ||||||
|  | @ -261,3 +261,60 @@ export const toggleSignUpEnabledStatus = async (token: string) => { | ||||||
| 
 | 
 | ||||||
| 	return res; | 	return res; | ||||||
| }; | }; | ||||||
|  | 
 | ||||||
|  | export const getJWTExpiresDuration = async (token: string) => { | ||||||
|  | 	let error = null; | ||||||
|  | 
 | ||||||
|  | 	const res = await fetch(`${WEBUI_API_BASE_URL}/auths/token/expires`, { | ||||||
|  | 		method: 'GET', | ||||||
|  | 		headers: { | ||||||
|  | 			'Content-Type': 'application/json', | ||||||
|  | 			Authorization: `Bearer ${token}` | ||||||
|  | 		} | ||||||
|  | 	}) | ||||||
|  | 		.then(async (res) => { | ||||||
|  | 			if (!res.ok) throw await res.json(); | ||||||
|  | 			return res.json(); | ||||||
|  | 		}) | ||||||
|  | 		.catch((err) => { | ||||||
|  | 			console.log(err); | ||||||
|  | 			error = err.detail; | ||||||
|  | 			return null; | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 	if (error) { | ||||||
|  | 		throw error; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return res; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export const updateJWTExpiresDuration = async (token: string, duration: string) => { | ||||||
|  | 	let error = null; | ||||||
|  | 
 | ||||||
|  | 	const res = await fetch(`${WEBUI_API_BASE_URL}/auths/token/expires/update`, { | ||||||
|  | 		method: 'POST', | ||||||
|  | 		headers: { | ||||||
|  | 			'Content-Type': 'application/json', | ||||||
|  | 			Authorization: `Bearer ${token}` | ||||||
|  | 		}, | ||||||
|  | 		body: JSON.stringify({ | ||||||
|  | 			duration: duration | ||||||
|  | 		}) | ||||||
|  | 	}) | ||||||
|  | 		.then(async (res) => { | ||||||
|  | 			if (!res.ok) throw await res.json(); | ||||||
|  | 			return res.json(); | ||||||
|  | 		}) | ||||||
|  | 		.catch((err) => { | ||||||
|  | 			console.log(err); | ||||||
|  | 			error = err.detail; | ||||||
|  | 			return null; | ||||||
|  | 		}); | ||||||
|  | 
 | ||||||
|  | 	if (error) { | ||||||
|  | 		throw error; | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return res; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | @ -1,15 +1,18 @@ | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| 	import { | 	import { | ||||||
| 		getDefaultUserRole, | 		getDefaultUserRole, | ||||||
|  | 		getJWTExpiresDuration, | ||||||
| 		getSignUpEnabledStatus, | 		getSignUpEnabledStatus, | ||||||
| 		toggleSignUpEnabledStatus, | 		toggleSignUpEnabledStatus, | ||||||
| 		updateDefaultUserRole | 		updateDefaultUserRole, | ||||||
|  | 		updateJWTExpiresDuration | ||||||
| 	} from '$lib/apis/auths'; | 	} from '$lib/apis/auths'; | ||||||
| 	import { onMount } from 'svelte'; | 	import { onMount } from 'svelte'; | ||||||
| 
 | 
 | ||||||
| 	export let saveHandler: Function; | 	export let saveHandler: Function; | ||||||
| 	let signUpEnabled = true; | 	let signUpEnabled = true; | ||||||
| 	let defaultUserRole = 'pending'; | 	let defaultUserRole = 'pending'; | ||||||
|  | 	let JWTExpiresIn = ''; | ||||||
| 
 | 
 | ||||||
| 	const toggleSignUpEnabled = async () => { | 	const toggleSignUpEnabled = async () => { | ||||||
| 		signUpEnabled = await toggleSignUpEnabledStatus(localStorage.token); | 		signUpEnabled = await toggleSignUpEnabledStatus(localStorage.token); | ||||||
|  | @ -19,9 +22,14 @@ | ||||||
| 		defaultUserRole = await updateDefaultUserRole(localStorage.token, role); | 		defaultUserRole = await updateDefaultUserRole(localStorage.token, role); | ||||||
| 	}; | 	}; | ||||||
| 
 | 
 | ||||||
|  | 	const updateJWTExpiresDurationHandler = async (duration) => { | ||||||
|  | 		JWTExpiresIn = await updateJWTExpiresDuration(localStorage.token, duration); | ||||||
|  | 	}; | ||||||
|  | 
 | ||||||
| 	onMount(async () => { | 	onMount(async () => { | ||||||
| 		signUpEnabled = await getSignUpEnabledStatus(localStorage.token); | 		signUpEnabled = await getSignUpEnabledStatus(localStorage.token); | ||||||
| 		defaultUserRole = await getDefaultUserRole(localStorage.token); | 		defaultUserRole = await getDefaultUserRole(localStorage.token); | ||||||
|  | 		JWTExpiresIn = await getJWTExpiresDuration(localStorage.token); | ||||||
| 	}); | 	}); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
|  | @ -29,6 +37,7 @@ | ||||||
| 	class="flex flex-col h-full justify-between space-y-3 text-sm" | 	class="flex flex-col h-full justify-between space-y-3 text-sm" | ||||||
| 	on:submit|preventDefault={() => { | 	on:submit|preventDefault={() => { | ||||||
| 		// console.log('submit'); | 		// console.log('submit'); | ||||||
|  | 		updateJWTExpiresDurationHandler(JWTExpiresIn); | ||||||
| 		saveHandler(); | 		saveHandler(); | ||||||
| 	}} | 	}} | ||||||
| > | > | ||||||
|  | @ -94,6 +103,29 @@ | ||||||
| 					</select> | 					</select> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
|  | 
 | ||||||
|  | 			<hr class=" dark:border-gray-700 my-3" /> | ||||||
|  | 
 | ||||||
|  | 			<div class=" w-full justify-between"> | ||||||
|  | 				<div class="flex w-full justify-between"> | ||||||
|  | 					<div class=" self-center text-xs font-medium">JWT Expiration</div> | ||||||
|  | 				</div> | ||||||
|  | 
 | ||||||
|  | 				<div class="flex mt-2 space-x-2"> | ||||||
|  | 					<input | ||||||
|  | 						class="w-full rounded py-1.5 px-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none border border-gray-100 dark:border-gray-600" | ||||||
|  | 						type="text" | ||||||
|  | 						placeholder={`e.g.) "30m","1h", "10d". `} | ||||||
|  | 						bind:value={JWTExpiresIn} | ||||||
|  | 					/> | ||||||
|  | 				</div> | ||||||
|  | 
 | ||||||
|  | 				<div class="mt-2 text-xs text-gray-400 dark:text-gray-500"> | ||||||
|  | 					Valid time units: <span class=" text-gray-300 font-medium" | ||||||
|  | 						>'s', 'm', 'h', 'd', 'w' or '-1' for no expiration.</span | ||||||
|  | 					> | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
| 		</div> | 		</div> | ||||||
| 	</div> | 	</div> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -7,11 +7,14 @@ | ||||||
| 
 | 
 | ||||||
| 	import UpdatePassword from './Account/UpdatePassword.svelte'; | 	import UpdatePassword from './Account/UpdatePassword.svelte'; | ||||||
| 	import { getGravatarUrl } from '$lib/apis/utils'; | 	import { getGravatarUrl } from '$lib/apis/utils'; | ||||||
|  | 	import { copyToClipboard } from '$lib/utils'; | ||||||
| 
 | 
 | ||||||
| 	export let saveHandler: Function; | 	export let saveHandler: Function; | ||||||
| 
 | 
 | ||||||
| 	let profileImageUrl = ''; | 	let profileImageUrl = ''; | ||||||
| 	let name = ''; | 	let name = ''; | ||||||
|  | 	let showJWTToken = false; | ||||||
|  | 	let JWTTokenCopied = false; | ||||||
| 
 | 
 | ||||||
| 	const submitHandler = async () => { | 	const submitHandler = async () => { | ||||||
| 		const updatedUser = await updateUserProfile(localStorage.token, name, profileImageUrl).catch( | 		const updatedUser = await updateUserProfile(localStorage.token, name, profileImageUrl).catch( | ||||||
|  | @ -160,6 +163,108 @@ | ||||||
| 
 | 
 | ||||||
| 		<hr class=" dark:border-gray-700 my-4" /> | 		<hr class=" dark:border-gray-700 my-4" /> | ||||||
| 		<UpdatePassword /> | 		<UpdatePassword /> | ||||||
|  | 
 | ||||||
|  | 		<hr class=" dark:border-gray-700 my-4" /> | ||||||
|  | 
 | ||||||
|  | 		<div class=" w-full justify-between"> | ||||||
|  | 			<div class="flex w-full justify-between"> | ||||||
|  | 				<div class=" self-center text-xs font-medium">JWT Token</div> | ||||||
|  | 			</div> | ||||||
|  | 
 | ||||||
|  | 			<div class="flex mt-2"> | ||||||
|  | 				<div class="flex w-full"> | ||||||
|  | 					<input | ||||||
|  | 						class="w-full rounded-l-lg py-1.5 pl-4 text-sm dark:text-gray-300 dark:bg-gray-800 outline-none" | ||||||
|  | 						type={showJWTToken ? 'text' : 'password'} | ||||||
|  | 						value={localStorage.token} | ||||||
|  | 						disabled | ||||||
|  | 					/> | ||||||
|  | 
 | ||||||
|  | 					<button | ||||||
|  | 						class="dark:bg-gray-800 px-2 transition rounded-r-lg" | ||||||
|  | 						on:click={() => { | ||||||
|  | 							showJWTToken = !showJWTToken; | ||||||
|  | 						}} | ||||||
|  | 					> | ||||||
|  | 						{#if showJWTToken} | ||||||
|  | 							<svg | ||||||
|  | 								xmlns="http://www.w3.org/2000/svg" | ||||||
|  | 								viewBox="0 0 16 16" | ||||||
|  | 								fill="currentColor" | ||||||
|  | 								class="w-4 h-4" | ||||||
|  | 							> | ||||||
|  | 								<path | ||||||
|  | 									fill-rule="evenodd" | ||||||
|  | 									d="M3.28 2.22a.75.75 0 0 0-1.06 1.06l10.5 10.5a.75.75 0 1 0 1.06-1.06l-1.322-1.323a7.012 7.012 0 0 0 2.16-3.11.87.87 0 0 0 0-.567A7.003 7.003 0 0 0 4.82 3.76l-1.54-1.54Zm3.196 3.195 1.135 1.136A1.502 1.502 0 0 1 9.45 8.389l1.136 1.135a3 3 0 0 0-4.109-4.109Z" | ||||||
|  | 									clip-rule="evenodd" | ||||||
|  | 								/> | ||||||
|  | 								<path | ||||||
|  | 									d="m7.812 10.994 1.816 1.816A7.003 7.003 0 0 1 1.38 8.28a.87.87 0 0 1 0-.566 6.985 6.985 0 0 1 1.113-2.039l2.513 2.513a3 3 0 0 0 2.806 2.806Z" | ||||||
|  | 								/> | ||||||
|  | 							</svg> | ||||||
|  | 						{:else} | ||||||
|  | 							<svg | ||||||
|  | 								xmlns="http://www.w3.org/2000/svg" | ||||||
|  | 								viewBox="0 0 16 16" | ||||||
|  | 								fill="currentColor" | ||||||
|  | 								class="w-4 h-4" | ||||||
|  | 							> | ||||||
|  | 								<path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" /> | ||||||
|  | 								<path | ||||||
|  | 									fill-rule="evenodd" | ||||||
|  | 									d="M1.38 8.28a.87.87 0 0 1 0-.566 7.003 7.003 0 0 1 13.238.006.87.87 0 0 1 0 .566A7.003 7.003 0 0 1 1.379 8.28ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" | ||||||
|  | 									clip-rule="evenodd" | ||||||
|  | 								/> | ||||||
|  | 							</svg> | ||||||
|  | 						{/if} | ||||||
|  | 					</button> | ||||||
|  | 				</div> | ||||||
|  | 
 | ||||||
|  | 				<button | ||||||
|  | 					class="ml-1.5 px-1.5 py-1 hover:bg-gray-800 transition rounded-lg" | ||||||
|  | 					on:click={() => { | ||||||
|  | 						copyToClipboard(localStorage.token); | ||||||
|  | 						JWTTokenCopied = true; | ||||||
|  | 						setTimeout(() => { | ||||||
|  | 							JWTTokenCopied = false; | ||||||
|  | 						}, 2000); | ||||||
|  | 					}} | ||||||
|  | 				> | ||||||
|  | 					{#if JWTTokenCopied} | ||||||
|  | 						<svg | ||||||
|  | 							xmlns="http://www.w3.org/2000/svg" | ||||||
|  | 							viewBox="0 0 20 20" | ||||||
|  | 							fill="currentColor" | ||||||
|  | 							class="w-4 h-4" | ||||||
|  | 						> | ||||||
|  | 							<path | ||||||
|  | 								fill-rule="evenodd" | ||||||
|  | 								d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" | ||||||
|  | 								clip-rule="evenodd" | ||||||
|  | 							/> | ||||||
|  | 						</svg> | ||||||
|  | 					{:else} | ||||||
|  | 						<svg | ||||||
|  | 							xmlns="http://www.w3.org/2000/svg" | ||||||
|  | 							viewBox="0 0 16 16" | ||||||
|  | 							fill="currentColor" | ||||||
|  | 							class="w-4 h-4" | ||||||
|  | 						> | ||||||
|  | 							<path | ||||||
|  | 								fill-rule="evenodd" | ||||||
|  | 								d="M11.986 3H12a2 2 0 0 1 2 2v6a2 2 0 0 1-1.5 1.937V7A2.5 2.5 0 0 0 10 4.5H4.063A2 2 0 0 1 6 3h.014A2.25 2.25 0 0 1 8.25 1h1.5a2.25 2.25 0 0 1 2.236 2ZM10.5 4v-.75a.75.75 0 0 0-.75-.75h-1.5a.75.75 0 0 0-.75.75V4h3Z" | ||||||
|  | 								clip-rule="evenodd" | ||||||
|  | 							/> | ||||||
|  | 							<path | ||||||
|  | 								fill-rule="evenodd" | ||||||
|  | 								d="M3 6a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3Zm1.75 2.5a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5ZM4 11.75a.75.75 0 0 1 .75-.75h3.5a.75.75 0 0 1 0 1.5h-3.5a.75.75 0 0 1-.75-.75Z" | ||||||
|  | 								clip-rule="evenodd" | ||||||
|  | 							/> | ||||||
|  | 						</svg> | ||||||
|  | 					{/if} | ||||||
|  | 				</button> | ||||||
|  | 			</div> | ||||||
|  | 		</div> | ||||||
| 	</div> | 	</div> | ||||||
| 
 | 
 | ||||||
| 	<div class="flex justify-end pt-3 text-sm font-medium"> | 	<div class="flex justify-end pt-3 text-sm font-medium"> | ||||||
|  |  | ||||||
|  | @ -39,7 +39,7 @@ | ||||||
| 		updatePasswordHandler(); | 		updatePasswordHandler(); | ||||||
| 	}} | 	}} | ||||||
| > | > | ||||||
| 	<div class="flex justify-between mb-2.5 items-center text-sm"> | 	<div class="flex justify-between items-center text-sm"> | ||||||
| 		<div class="  font-medium">Change Password</div> | 		<div class="  font-medium">Change Password</div> | ||||||
| 		<button | 		<button | ||||||
| 			class=" text-xs font-medium text-gray-500" | 			class=" text-xs font-medium text-gray-500" | ||||||
|  | @ -51,7 +51,7 @@ | ||||||
| 	</div> | 	</div> | ||||||
| 
 | 
 | ||||||
| 	{#if show} | 	{#if show} | ||||||
| 		<div class=" space-y-1.5"> | 		<div class=" py-2.5 space-y-1.5"> | ||||||
| 			<div class="flex flex-col w-full"> | 			<div class="flex flex-col w-full"> | ||||||
| 				<div class=" mb-1 text-xs text-gray-500">Current Password</div> | 				<div class=" mb-1 text-xs text-gray-500">Current Password</div> | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Timothy J. Baek
						Timothy J. Baek