Major cleanup, refactoring, device registrations.
This commit is contained in:
parent
5a92c6a25f
commit
58368e2de3
@ -16,8 +16,8 @@ from .jwt_manager import refresh_session_token, validate_session_token
|
|||||||
from .session_manager import (
|
from .session_manager import (
|
||||||
clear_session_cookie,
|
clear_session_cookie,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
get_session_token_from_auth_header_or_body,
|
get_session_token_from_bearer,
|
||||||
get_session_token_from_request,
|
get_session_token_from_cookie,
|
||||||
set_session_cookie,
|
set_session_cookie,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -52,7 +52,7 @@ async def get_user_credentials(request: Request) -> dict:
|
|||||||
|
|
||||||
# Get current session credential ID
|
# Get current session credential ID
|
||||||
current_credential_id = None
|
current_credential_id = None
|
||||||
session_token = get_session_token_from_request(request)
|
session_token = get_session_token_from_cookie(request)
|
||||||
if session_token:
|
if session_token:
|
||||||
token_data = validate_session_token(session_token)
|
token_data = validate_session_token(session_token)
|
||||||
if token_data:
|
if token_data:
|
||||||
@ -65,34 +65,30 @@ async def get_user_credentials(request: Request) -> dict:
|
|||||||
user_aaguids = set()
|
user_aaguids = set()
|
||||||
|
|
||||||
for cred_id in credential_ids:
|
for cred_id in credential_ids:
|
||||||
try:
|
stored_cred = await db.get_credential_by_id(cred_id)
|
||||||
stored_cred = await db.get_credential_by_id(cred_id)
|
|
||||||
|
|
||||||
# Convert AAGUID to string format
|
# Convert AAGUID to string format
|
||||||
aaguid_str = str(stored_cred.aaguid)
|
aaguid_str = str(stored_cred.aaguid)
|
||||||
user_aaguids.add(aaguid_str)
|
user_aaguids.add(aaguid_str)
|
||||||
|
|
||||||
# Check if this is the current session credential
|
# Check if this is the current session credential
|
||||||
is_current_session = current_credential_id == stored_cred.credential_id
|
is_current_session = current_credential_id == stored_cred.credential_id
|
||||||
|
|
||||||
credentials.append(
|
credentials.append(
|
||||||
{
|
{
|
||||||
"credential_id": stored_cred.credential_id.hex(),
|
"credential_id": stored_cred.credential_id.hex(),
|
||||||
"aaguid": aaguid_str,
|
"aaguid": aaguid_str,
|
||||||
"created_at": stored_cred.created_at.isoformat(),
|
"created_at": stored_cred.created_at.isoformat(),
|
||||||
"last_used": stored_cred.last_used.isoformat()
|
"last_used": stored_cred.last_used.isoformat()
|
||||||
if stored_cred.last_used
|
if stored_cred.last_used
|
||||||
else None,
|
else None,
|
||||||
"last_verified": stored_cred.last_verified.isoformat()
|
"last_verified": stored_cred.last_verified.isoformat()
|
||||||
if stored_cred.last_verified
|
if stored_cred.last_verified
|
||||||
else None,
|
else None,
|
||||||
"sign_count": stored_cred.sign_count,
|
"sign_count": stored_cred.sign_count,
|
||||||
"is_current_session": is_current_session,
|
"is_current_session": is_current_session,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except ValueError:
|
|
||||||
# Skip invalid credentials
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Get AAGUID information for only the AAGUIDs that the user has
|
# Get AAGUID information for only the AAGUIDs that the user has
|
||||||
aaguid_manager = get_aaguid_manager()
|
aaguid_manager = get_aaguid_manager()
|
||||||
@ -113,7 +109,7 @@ async def get_user_credentials(request: Request) -> dict:
|
|||||||
async def refresh_token(request: Request, response: Response) -> dict:
|
async def refresh_token(request: Request, response: Response) -> dict:
|
||||||
"""Refresh the session token."""
|
"""Refresh the session token."""
|
||||||
try:
|
try:
|
||||||
session_token = get_session_token_from_request(request)
|
session_token = get_session_token_from_cookie(request)
|
||||||
if not session_token:
|
if not session_token:
|
||||||
return {"error": "No session token found"}
|
return {"error": "No session token found"}
|
||||||
|
|
||||||
@ -134,7 +130,7 @@ async def refresh_token(request: Request, response: Response) -> dict:
|
|||||||
async def validate_token(request: Request) -> dict:
|
async def validate_token(request: Request) -> dict:
|
||||||
"""Validate a session token and return user info."""
|
"""Validate a session token and return user info."""
|
||||||
try:
|
try:
|
||||||
session_token = get_session_token_from_request(request)
|
session_token = get_session_token_from_cookie(request)
|
||||||
if not session_token:
|
if not session_token:
|
||||||
return {"error": "No session token found"}
|
return {"error": "No session token found"}
|
||||||
|
|
||||||
@ -165,7 +161,7 @@ async def logout(response: Response) -> dict:
|
|||||||
async def set_session(request: Request, response: Response) -> dict:
|
async def set_session(request: Request, response: Response) -> dict:
|
||||||
"""Set session cookie using JWT token from request body or Authorization header."""
|
"""Set session cookie using JWT token from request body or Authorization header."""
|
||||||
try:
|
try:
|
||||||
session_token = await get_session_token_from_auth_header_or_body(request)
|
session_token = await get_session_token_from_bearer(request)
|
||||||
|
|
||||||
if not session_token:
|
if not session_token:
|
||||||
return {"error": "No session token provided"}
|
return {"error": "No session token provided"}
|
||||||
@ -219,7 +215,7 @@ async def delete_credential(request: Request) -> dict:
|
|||||||
return {"error": "Credential not found"}
|
return {"error": "Credential not found"}
|
||||||
|
|
||||||
# Check if this is the current session credential
|
# Check if this is the current session credential
|
||||||
session_token = get_session_token_from_request(request)
|
session_token = get_session_token_from_cookie(request)
|
||||||
if session_token:
|
if session_token:
|
||||||
token_data = validate_session_token(session_token)
|
token_data = validate_session_token(session_token)
|
||||||
if token_data and token_data.get("credential_id") == credential_id_bytes:
|
if token_data and token_data.get("credential_id") == credential_id_bytes:
|
||||||
|
@ -9,14 +9,19 @@ This module provides a simple WebAuthn implementation that:
|
|||||||
- Enables true passwordless authentication where users don't need to enter a user_name
|
- Enables true passwordless authentication where users don't need to enter a user_name
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.responses import FileResponse
|
from fastapi import (
|
||||||
|
Path as FastAPIPath,
|
||||||
|
)
|
||||||
|
from fastapi.responses import FileResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
|
||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
from .api_handlers import (
|
from .api_handlers import (
|
||||||
@ -40,7 +45,7 @@ STATIC_DIR = Path(__file__).parent.parent / "static"
|
|||||||
passkey = Passkey(
|
passkey = Passkey(
|
||||||
rp_id="localhost",
|
rp_id="localhost",
|
||||||
rp_name="Passkey Auth",
|
rp_name="Passkey Auth",
|
||||||
origin="http://localhost:8000",
|
origin="http://localhost:3000",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -53,15 +58,12 @@ async def lifespan(app: FastAPI):
|
|||||||
app = FastAPI(title="Passkey Auth", lifespan=lifespan)
|
app = FastAPI(title="Passkey Auth", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/new_user_registration")
|
@app.websocket("/auth/ws/register_new")
|
||||||
async def websocket_register_new(ws: WebSocket):
|
async def websocket_register_new(ws: WebSocket, user_name: str):
|
||||||
"""Register a new user and with a new passkey credential."""
|
"""Register a new user and with a new passkey credential."""
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
try:
|
try:
|
||||||
# Data for the new user account
|
|
||||||
form = await ws.receive_json()
|
|
||||||
user_id = uuid4()
|
user_id = uuid4()
|
||||||
user_name = form["user_name"]
|
|
||||||
|
|
||||||
# WebAuthn registration
|
# WebAuthn registration
|
||||||
credential = await register_chat(ws, user_id, user_name)
|
credential = await register_chat(ws, user_id, user_name)
|
||||||
@ -86,9 +88,12 @@ async def websocket_register_new(ws: WebSocket):
|
|||||||
await ws.send_json({"error": str(e)})
|
await ws.send_json({"error": str(e)})
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Internal Server Error")
|
||||||
|
await ws.send_json({"error": "Internal Server Error"})
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/add_credential")
|
@app.websocket("/auth/ws/add_credential")
|
||||||
async def websocket_register_add(ws: WebSocket):
|
async def websocket_register_add(ws: WebSocket):
|
||||||
"""Register a new credential for an existing user."""
|
"""Register a new credential for an existing user."""
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
@ -108,7 +113,6 @@ async def websocket_register_add(ws: WebSocket):
|
|||||||
|
|
||||||
# WebAuthn registration
|
# WebAuthn registration
|
||||||
credential = await register_chat(ws, user_id, user_name, challenge_ids)
|
credential = await register_chat(ws, user_id, user_name, challenge_ids)
|
||||||
print(f"New credential for user {user_id}: {credential}")
|
|
||||||
# Store the new credential in the database
|
# Store the new credential in the database
|
||||||
await db.create_credential_for_user(credential)
|
await db.create_credential_for_user(credential)
|
||||||
|
|
||||||
@ -124,24 +128,16 @@ async def websocket_register_add(ws: WebSocket):
|
|||||||
await ws.send_json({"error": str(e)})
|
await ws.send_json({"error": str(e)})
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception:
|
||||||
await ws.send_json({"error": f"Server error: {str(e)}"})
|
logging.exception("Internal Server Error")
|
||||||
|
await ws.send_json({"error": "Internal Server Error"})
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/add_device_credential")
|
@app.websocket("/auth/ws/add_device_credential")
|
||||||
async def websocket_add_device_credential(ws: WebSocket):
|
async def websocket_add_device_credential(ws: WebSocket, token: str):
|
||||||
"""Add a new credential for an existing user via device addition token."""
|
"""Add a new credential for an existing user via device addition token."""
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
try:
|
try:
|
||||||
# Get device addition token from client
|
|
||||||
message = await ws.receive_json()
|
|
||||||
token = message.get("token")
|
|
||||||
|
|
||||||
if not token:
|
|
||||||
await ws.send_json({"error": "Device addition token is required"})
|
|
||||||
return
|
|
||||||
|
|
||||||
# Validate device addition token
|
|
||||||
reset_token = await db.get_reset_token(token)
|
reset_token = await db.get_reset_token(token)
|
||||||
if not reset_token:
|
if not reset_token:
|
||||||
await ws.send_json({"error": "Invalid or expired device addition token"})
|
await ws.send_json({"error": "Invalid or expired device addition token"})
|
||||||
@ -182,8 +178,9 @@ async def websocket_add_device_credential(ws: WebSocket):
|
|||||||
await ws.send_json({"error": str(e)})
|
await ws.send_json({"error": str(e)})
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception:
|
||||||
await ws.send_json({"error": f"Server error: {str(e)}"})
|
logging.exception("Internal Server Error")
|
||||||
|
await ws.send_json({"error": "Internal Server Error"})
|
||||||
|
|
||||||
|
|
||||||
async def register_chat(
|
async def register_chat(
|
||||||
@ -200,11 +197,10 @@ async def register_chat(
|
|||||||
)
|
)
|
||||||
await ws.send_json(options)
|
await ws.send_json(options)
|
||||||
response = await ws.receive_json()
|
response = await ws.receive_json()
|
||||||
print(response)
|
|
||||||
return passkey.reg_verify(response, challenge, user_id)
|
return passkey.reg_verify(response, challenge, user_id)
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/authenticate")
|
@app.websocket("/auth/ws/authenticate")
|
||||||
async def websocket_authenticate(ws: WebSocket):
|
async def websocket_authenticate(ws: WebSocket):
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
try:
|
try:
|
||||||
@ -231,114 +227,109 @@ async def websocket_authenticate(ws: WebSocket):
|
|||||||
"session_token": session_token,
|
"session_token": session_token,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except ValueError as e:
|
except (ValueError, InvalidAuthenticationResponse) as e:
|
||||||
|
logging.exception("ValueError")
|
||||||
await ws.send_json({"error": str(e)})
|
await ws.send_json({"error": str(e)})
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
pass
|
pass
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Internal Server Error")
|
||||||
|
await ws.send_json({"error": "Internal Server Error"})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/user-info")
|
@app.get("/auth/user-info")
|
||||||
async def api_get_user_info(request: Request):
|
async def api_get_user_info(request: Request):
|
||||||
"""Get user information from session cookie."""
|
"""Get user information from session cookie."""
|
||||||
return await get_user_info(request)
|
return await get_user_info(request)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/user-credentials")
|
@app.get("/auth/user-credentials")
|
||||||
async def api_get_user_credentials(request: Request):
|
async def api_get_user_credentials(request: Request):
|
||||||
"""Get all credentials for a user using session cookie."""
|
"""Get all credentials for a user using session cookie."""
|
||||||
return await get_user_credentials(request)
|
return await get_user_credentials(request)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/refresh-token")
|
@app.post("/auth/refresh-token")
|
||||||
async def api_refresh_token(request: Request, response: Response):
|
async def api_refresh_token(request: Request, response: Response):
|
||||||
"""Refresh the session token."""
|
"""Refresh the session token."""
|
||||||
return await refresh_token(request, response)
|
return await refresh_token(request, response)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/validate-token")
|
@app.get("/auth/validate-token")
|
||||||
async def api_validate_token(request: Request):
|
async def api_validate_token(request: Request):
|
||||||
"""Validate a session token and return user info."""
|
"""Validate a session token and return user info."""
|
||||||
return await validate_token(request)
|
return await validate_token(request)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/logout")
|
@app.post("/auth/logout")
|
||||||
async def api_logout(response: Response):
|
async def api_logout(response: Response):
|
||||||
"""Log out the current user by clearing the session cookie."""
|
"""Log out the current user by clearing the session cookie."""
|
||||||
return await logout(response)
|
return await logout(response)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/set-session")
|
@app.post("/auth/set-session")
|
||||||
async def api_set_session(request: Request, response: Response):
|
async def api_set_session(request: Request, response: Response):
|
||||||
"""Set session cookie using JWT token from request body or Authorization header."""
|
"""Set session cookie using JWT token from request body or Authorization header."""
|
||||||
return await set_session(request, response)
|
return await set_session(request, response)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/delete-credential")
|
@app.post("/auth/delete-credential")
|
||||||
async def api_delete_credential(request: Request):
|
async def api_delete_credential(request: Request):
|
||||||
"""Delete a specific credential for the current user."""
|
"""Delete a specific credential for the current user."""
|
||||||
return await delete_credential(request)
|
return await delete_credential(request)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/create-device-link")
|
@app.post("/auth/create-device-link")
|
||||||
async def api_create_device_link(request: Request):
|
async def api_create_device_link(request: Request):
|
||||||
"""Create a device addition link for the authenticated user."""
|
"""Create a device addition link for the authenticated user."""
|
||||||
return await create_device_addition_link(request)
|
return await create_device_addition_link(request)
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/validate-device-token")
|
@app.post("/auth/validate-device-token")
|
||||||
async def api_validate_device_token(request: Request):
|
async def api_validate_device_token(request: Request):
|
||||||
"""Validate a device addition token."""
|
"""Validate a device addition token."""
|
||||||
return await validate_device_addition_token(request)
|
return await validate_device_addition_token(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/{passphrase}")
|
||||||
|
async def reset_authentication(
|
||||||
|
passphrase: str = FastAPIPath(pattern=r"^\w+(\.\w+){2,}$"),
|
||||||
|
):
|
||||||
|
response = RedirectResponse(url="/", status_code=303)
|
||||||
|
response.set_cookie(
|
||||||
|
key="auth-token",
|
||||||
|
value=passphrase,
|
||||||
|
httponly=False,
|
||||||
|
secure=True,
|
||||||
|
samesite="strict",
|
||||||
|
max_age=2,
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/auth/user-info-by-passphrase")
|
||||||
|
async def api_get_user_info_by_passphrase(token: str):
|
||||||
|
"""Get user information using the passphrase."""
|
||||||
|
reset_token = await db.get_reset_token(token)
|
||||||
|
if not reset_token:
|
||||||
|
return Response(content="Invalid or expired passphrase", status_code=403)
|
||||||
|
|
||||||
|
user = await db.get_user_by_id(reset_token.user_id)
|
||||||
|
if not user:
|
||||||
|
return Response(content="User not found", status_code=404)
|
||||||
|
|
||||||
|
return {"user_name": user.user_name}
|
||||||
|
|
||||||
|
|
||||||
# Serve static files
|
# Serve static files
|
||||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
# Catch-all route for SPA - serve index.html for all non-API routes
|
||||||
async def get_index():
|
@app.get("/{path:path}")
|
||||||
"""Redirect to login page"""
|
async def spa_handler(path: str):
|
||||||
from fastapi.responses import RedirectResponse
|
"""Serve the Vue SPA for all routes (except API and static)"""
|
||||||
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
return RedirectResponse(url="/auth/login", status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/login")
|
|
||||||
async def get_login_page():
|
|
||||||
"""Serve the login page"""
|
|
||||||
return FileResponse(STATIC_DIR / "login.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/register")
|
|
||||||
async def get_register_page():
|
|
||||||
"""Serve the register page"""
|
|
||||||
return FileResponse(STATIC_DIR / "register.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/dashboard")
|
|
||||||
async def get_dashboard_page():
|
|
||||||
"""Redirect to profile (dashboard is now profile)"""
|
|
||||||
from fastapi.responses import RedirectResponse
|
|
||||||
|
|
||||||
return RedirectResponse(url="/auth/profile", status_code=302)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/profile")
|
|
||||||
async def get_profile_page():
|
|
||||||
"""Serve the profile page"""
|
|
||||||
return FileResponse(STATIC_DIR / "profile.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/auth/reset")
|
|
||||||
async def get_reset_page_without_token():
|
|
||||||
"""Serve the reset page without a token"""
|
|
||||||
return FileResponse(STATIC_DIR / "reset.html")
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/reset/{token}")
|
|
||||||
async def get_reset_page(token: str):
|
|
||||||
"""Serve the reset page with the token in URL"""
|
|
||||||
return FileResponse(STATIC_DIR / "reset.html")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@ -355,4 +346,5 @@ def main():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
main()
|
main()
|
||||||
|
@ -60,7 +60,7 @@ class Passkey:
|
|||||||
self,
|
self,
|
||||||
rp_id: str,
|
rp_id: str,
|
||||||
rp_name: str,
|
rp_name: str,
|
||||||
origin: str,
|
origin: str | None = None,
|
||||||
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
|
supported_pub_key_algs: list[COSEAlgorithmIdentifier] | None = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
@ -74,7 +74,7 @@ class Passkey:
|
|||||||
"""
|
"""
|
||||||
self.rp_id = rp_id
|
self.rp_id = rp_id
|
||||||
self.rp_name = rp_name
|
self.rp_name = rp_name
|
||||||
self.origin = origin
|
self.origin = origin or f"https://{rp_id}"
|
||||||
self.supported_pub_key_algs = supported_pub_key_algs or [
|
self.supported_pub_key_algs = supported_pub_key_algs or [
|
||||||
COSEAlgorithmIdentifier.EDDSA,
|
COSEAlgorithmIdentifier.EDDSA,
|
||||||
COSEAlgorithmIdentifier.ECDSA_SHA_256,
|
COSEAlgorithmIdentifier.ECDSA_SHA_256,
|
||||||
|
@ -25,19 +25,18 @@ async def create_device_addition_link(request: Request) -> dict:
|
|||||||
return {"error": "Authentication required"}
|
return {"error": "Authentication required"}
|
||||||
|
|
||||||
# Generate a human-readable token
|
# Generate a human-readable token
|
||||||
token = generate(n=4, sep="-") # e.g., "able-ocean-forest-dawn"
|
token = generate(n=4, sep=".") # e.g., "able-ocean-forest-dawn"
|
||||||
|
|
||||||
# Create reset token in database
|
# Create reset token in database
|
||||||
await db.create_reset_token(user.user_id, token)
|
await db.create_reset_token(user.user_id, token)
|
||||||
|
|
||||||
# Generate the device addition link with pretty URL
|
# Generate the device addition link with pretty URL
|
||||||
addition_link = f"http://localhost:8000/reset/{token}"
|
addition_link = f"{request.headers.get('origin', '')}/auth/{token}"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Device addition link generated successfully",
|
"message": "Device addition link generated successfully",
|
||||||
"addition_link": addition_link,
|
"addition_link": addition_link,
|
||||||
"token": token,
|
|
||||||
"expires_in_hours": 24,
|
"expires_in_hours": 24,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,7 +7,6 @@ This module provides session management functionality including:
|
|||||||
- Session validation and token handling
|
- Session validation and token handling
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Optional
|
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
@ -15,11 +14,11 @@ from fastapi import Request, Response
|
|||||||
from .db import User, get_user_by_id
|
from .db import User, get_user_by_id
|
||||||
from .jwt_manager import validate_session_token
|
from .jwt_manager import validate_session_token
|
||||||
|
|
||||||
COOKIE_NAME = "session_token"
|
COOKIE_NAME = "auth"
|
||||||
COOKIE_MAX_AGE = 86400 # 24 hours
|
COOKIE_MAX_AGE = 86400 # 24 hours
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(request: Request) -> Optional[User]:
|
async def get_current_user(request: Request) -> User | None:
|
||||||
"""Get the current user from the session cookie."""
|
"""Get the current user from the session cookie."""
|
||||||
session_token = request.cookies.get(COOKIE_NAME)
|
session_token = request.cookies.get(COOKIE_NAME)
|
||||||
if not session_token:
|
if not session_token:
|
||||||
@ -43,7 +42,7 @@ def set_session_cookie(response: Response, session_token: str) -> None:
|
|||||||
value=session_token,
|
value=session_token,
|
||||||
max_age=COOKIE_MAX_AGE,
|
max_age=COOKIE_MAX_AGE,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=False, # Set to True in production with HTTPS
|
secure=True,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -53,36 +52,29 @@ def clear_session_cookie(response: Response) -> None:
|
|||||||
response.delete_cookie(key=COOKIE_NAME)
|
response.delete_cookie(key=COOKIE_NAME)
|
||||||
|
|
||||||
|
|
||||||
def get_session_token_from_request(request: Request) -> Optional[str]:
|
def get_session_token_from_cookie(request: Request) -> str | None:
|
||||||
"""Extract session token from request cookies."""
|
"""Extract session token from request cookies."""
|
||||||
return request.cookies.get(COOKIE_NAME)
|
return request.cookies.get(COOKIE_NAME)
|
||||||
|
|
||||||
|
|
||||||
async def validate_session_from_request(request: Request) -> Optional[dict]:
|
async def validate_session_from_request(request: Request) -> dict | None:
|
||||||
"""Validate session token from request and return token data."""
|
"""Validate session token from request and return token data."""
|
||||||
session_token = get_session_token_from_request(request)
|
session_token = get_session_token_from_cookie(request)
|
||||||
if not session_token:
|
if not session_token:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return validate_session_token(session_token)
|
return validate_session_token(session_token)
|
||||||
|
|
||||||
|
|
||||||
async def get_session_token_from_auth_header_or_body(request: Request) -> Optional[str]:
|
async def get_session_token_from_bearer(request: Request) -> str | None:
|
||||||
"""Extract session token from Authorization header or request body."""
|
"""Extract session token from Authorization header or request body."""
|
||||||
# Try to get token from Authorization header first
|
# Try to get token from Authorization header first
|
||||||
auth_header = request.headers.get("Authorization")
|
auth_header = request.headers.get("Authorization")
|
||||||
if auth_header and auth_header.startswith("Bearer "):
|
if auth_header and auth_header.startswith("Bearer "):
|
||||||
return auth_header[7:] # Remove "Bearer " prefix
|
return auth_header.removeprefix("Bearer ")
|
||||||
|
|
||||||
# Try to get from request body
|
|
||||||
try:
|
|
||||||
body = await request.json()
|
|
||||||
return body.get("session_token")
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def get_user_from_cookie_string(cookie_header: str) -> Optional[UUID]:
|
async def get_user_from_cookie_string(cookie_header: str) -> UUID | None:
|
||||||
"""Parse cookie header and return user ID if valid session exists."""
|
"""Parse cookie header and return user ID if valid session exists."""
|
||||||
if not cookie_header:
|
if not cookie_header:
|
||||||
return None
|
return None
|
||||||
|
475
static/app.js
475
static/app.js
@ -1,475 +0,0 @@
|
|||||||
const { startRegistration, startAuthentication } = SimpleWebAuthnBrowser
|
|
||||||
|
|
||||||
// Global state
|
|
||||||
let currentUser = null
|
|
||||||
let currentCredentials = []
|
|
||||||
let aaguidInfo = {}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Session Management
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
async function validateStoredToken() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/validate-token', {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
return result.status === 'success'
|
|
||||||
} catch (error) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setSessionCookie(sessionToken) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/set-session', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${sessionToken}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Failed to set session cookie: ${error.message}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// View Management
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
function showView(viewId) {
|
|
||||||
document.querySelectorAll('.view').forEach(view => view.classList.remove('active'))
|
|
||||||
const targetView = document.getElementById(viewId)
|
|
||||||
if (targetView) {
|
|
||||||
targetView.classList.add('active')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLoginView() {
|
|
||||||
if (window.location.pathname !== '/auth/login') {
|
|
||||||
window.location.href = '/auth/login'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showView('loginView')
|
|
||||||
clearStatus('loginStatus')
|
|
||||||
}
|
|
||||||
|
|
||||||
function showRegisterView() {
|
|
||||||
if (window.location.pathname !== '/auth/register') {
|
|
||||||
window.location.href = '/auth/register'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showView('registerView')
|
|
||||||
clearStatus('registerStatus')
|
|
||||||
}
|
|
||||||
|
|
||||||
function showDeviceAdditionView() {
|
|
||||||
// This function is no longer needed as device addition is now a dialog
|
|
||||||
// Redirect to profile page if someone tries to access the old route
|
|
||||||
if (window.location.pathname === '/auth/add-device') {
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function showDashboardView() {
|
|
||||||
if (window.location.pathname !== '/auth/profile') {
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
showView('profileView')
|
|
||||||
clearStatus('profileStatus')
|
|
||||||
|
|
||||||
try {
|
|
||||||
await loadUserInfo()
|
|
||||||
updateUserInfo()
|
|
||||||
await loadCredentials()
|
|
||||||
} catch (error) {
|
|
||||||
showStatus('profileStatus', `Failed to load user info: ${error.message}`, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Status Management
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
function showStatus(elementId, message, type = 'info') {
|
|
||||||
const statusEl = document.getElementById(elementId)
|
|
||||||
statusEl.innerHTML = `<div class="status ${type}">${message}</div>`
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearStatus(elementId) {
|
|
||||||
document.getElementById(elementId).innerHTML = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Device Addition & QR Code
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
async function copyDeviceLink() {
|
|
||||||
try {
|
|
||||||
if (window.currentDeviceLink) {
|
|
||||||
await navigator.clipboard.writeText(window.currentDeviceLink)
|
|
||||||
|
|
||||||
const copyButton = document.querySelector('.copy-button')
|
|
||||||
const originalText = copyButton.textContent
|
|
||||||
copyButton.textContent = 'Copied!'
|
|
||||||
copyButton.style.background = '#28a745'
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
copyButton.textContent = originalText
|
|
||||||
copyButton.style.background = '#28a745'
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to copy link:', error)
|
|
||||||
const linkText = document.getElementById('deviceLinkText')
|
|
||||||
const range = document.createRange()
|
|
||||||
range.selectNode(linkText)
|
|
||||||
window.getSelection().removeAllRanges()
|
|
||||||
window.getSelection().addRange(range)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// WebAuthn Operations
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
async function register(user_name) {
|
|
||||||
const ws = await aWebSocket('/ws/new_user_registration')
|
|
||||||
|
|
||||||
ws.send(JSON.stringify({ user_name }))
|
|
||||||
|
|
||||||
const optionsJSON = JSON.parse(await ws.recv())
|
|
||||||
if (optionsJSON.error) throw new Error(optionsJSON.error)
|
|
||||||
|
|
||||||
const registrationResponse = await startRegistration({ optionsJSON })
|
|
||||||
ws.send(JSON.stringify(registrationResponse))
|
|
||||||
|
|
||||||
const result = JSON.parse(await ws.recv())
|
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
|
||||||
|
|
||||||
await setSessionCookie(result.session_token)
|
|
||||||
ws.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function authenticate() {
|
|
||||||
const ws = await aWebSocket('/ws/authenticate')
|
|
||||||
|
|
||||||
const optionsJSON = JSON.parse(await ws.recv())
|
|
||||||
if (optionsJSON.error) throw new Error(optionsJSON.error)
|
|
||||||
|
|
||||||
const authenticationResponse = await startAuthentication({ optionsJSON })
|
|
||||||
ws.send(JSON.stringify(authenticationResponse))
|
|
||||||
|
|
||||||
const result = JSON.parse(await ws.recv())
|
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
|
||||||
|
|
||||||
await setSessionCookie(result.session_token)
|
|
||||||
ws.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addNewCredential() {
|
|
||||||
try {
|
|
||||||
showStatus('dashboardStatus', 'Adding new passkey...', 'info')
|
|
||||||
|
|
||||||
const ws = await aWebSocket('/ws/add_credential')
|
|
||||||
|
|
||||||
const optionsJSON = JSON.parse(await ws.recv())
|
|
||||||
if (optionsJSON.error) throw new Error(optionsJSON.error)
|
|
||||||
|
|
||||||
const registrationResponse = await startRegistration({ optionsJSON })
|
|
||||||
ws.send(JSON.stringify(registrationResponse))
|
|
||||||
|
|
||||||
const result = JSON.parse(await ws.recv())
|
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
|
||||||
|
|
||||||
ws.close()
|
|
||||||
|
|
||||||
showStatus('dashboardStatus', 'New passkey added successfully!', 'success')
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
loadCredentials()
|
|
||||||
clearStatus('dashboardStatus')
|
|
||||||
}, 2000)
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
showStatus('dashboardStatus', `Failed to add passkey: ${error.message}`, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// User Data Management
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// User registration
|
|
||||||
async function register(user_name) {
|
|
||||||
try {
|
|
||||||
const ws = await aWebSocket('/ws/new_user_registration')
|
|
||||||
ws.send(JSON.stringify({user_name}))
|
|
||||||
|
|
||||||
// Registration chat
|
|
||||||
const optionsJSON = JSON.parse(await ws.recv())
|
|
||||||
if (optionsJSON.error) throw new Error(optionsJSON.error)
|
|
||||||
|
|
||||||
showStatus('registerStatus', 'Save to your authenticator...', 'info')
|
|
||||||
|
|
||||||
const registrationResponse = await startRegistration({optionsJSON})
|
|
||||||
ws.send(JSON.stringify(registrationResponse))
|
|
||||||
|
|
||||||
const result = JSON.parse(await ws.recv())
|
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
|
||||||
|
|
||||||
ws.close()
|
|
||||||
|
|
||||||
// Set session cookie using the JWT token
|
|
||||||
await setSessionCookie(result.session_token)
|
|
||||||
|
|
||||||
// Set current user from registration result
|
|
||||||
currentUser = {
|
|
||||||
user_id: result.user_id,
|
|
||||||
user_name: user_name,
|
|
||||||
last_seen: new Date().toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// User authentication
|
|
||||||
async function authenticate() {
|
|
||||||
try {
|
|
||||||
const ws = await aWebSocket('/ws/authenticate')
|
|
||||||
const optionsJSON = JSON.parse(await ws.recv())
|
|
||||||
if (optionsJSON.error) throw new Error(optionsJSON.error)
|
|
||||||
|
|
||||||
showStatus('loginStatus', 'Please use your authenticator...', 'info')
|
|
||||||
|
|
||||||
const authResponse = await startAuthentication({optionsJSON})
|
|
||||||
await ws.send(JSON.stringify(authResponse))
|
|
||||||
|
|
||||||
const result = JSON.parse(await ws.recv())
|
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
|
||||||
|
|
||||||
ws.close()
|
|
||||||
|
|
||||||
// Set session cookie using the JWT token
|
|
||||||
await setSessionCookie(result.session_token)
|
|
||||||
|
|
||||||
// Authentication successful, now get user info using HTTP endpoint
|
|
||||||
const userResponse = await fetch('/api/user-info', {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
|
|
||||||
const userInfo = await userResponse.json()
|
|
||||||
if (userInfo.error) throw new Error(`Server: ${userInfo.error}`)
|
|
||||||
|
|
||||||
currentUser = userInfo.user
|
|
||||||
|
|
||||||
return result
|
|
||||||
} catch (error) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load user credentials
|
|
||||||
async function loadCredentials() {
|
|
||||||
try {
|
|
||||||
const statusElement = document.getElementById('profileStatus') ? 'profileStatus' : 'dashboardStatus'
|
|
||||||
showStatus(statusElement, 'Loading credentials...', 'info')
|
|
||||||
|
|
||||||
const response = await fetch('/api/user-credentials', {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
|
||||||
|
|
||||||
currentCredentials = result.credentials
|
|
||||||
aaguidInfo = result.aaguid_info || {}
|
|
||||||
updateCredentialList()
|
|
||||||
clearStatus(statusElement)
|
|
||||||
} catch (error) {
|
|
||||||
const statusElement = document.getElementById('profileStatus') ? 'profileStatus' : 'dashboardStatus'
|
|
||||||
showStatus(statusElement, `Failed to load credentials: ${error.message}`, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load user info using HTTP endpoint
|
|
||||||
async function loadUserInfo() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/user-info', {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`)
|
|
||||||
|
|
||||||
currentUser = result.user
|
|
||||||
} catch (error) {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user info display
|
|
||||||
function updateUserInfo() {
|
|
||||||
const userInfoEl = document.getElementById('userInfo')
|
|
||||||
if (currentUser) {
|
|
||||||
userInfoEl.innerHTML = `
|
|
||||||
<h3>👤 ${currentUser.user_name}</h3>
|
|
||||||
<p><strong>Visits:</strong> ${currentUser.visits || 0}</p>
|
|
||||||
<p><strong>Member since:</strong> ${currentUser.created_at ? formatHumanReadableDate(currentUser.created_at) : 'N/A'}</p>
|
|
||||||
<p><strong>Last seen:</strong> ${currentUser.last_seen ? formatHumanReadableDate(currentUser.last_seen) : 'N/A'}</p>
|
|
||||||
`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update credential list display
|
|
||||||
function updateCredentialList() {
|
|
||||||
const credentialListEl = document.getElementById('credentialList')
|
|
||||||
|
|
||||||
if (currentCredentials.length === 0) {
|
|
||||||
credentialListEl.innerHTML = '<p>No passkeys found.</p>'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
credentialListEl.innerHTML = currentCredentials.map(cred => {
|
|
||||||
// Get authenticator information from AAGUID
|
|
||||||
const authInfo = aaguidInfo[cred.aaguid]
|
|
||||||
const authName = authInfo ? authInfo.name : 'Unknown Authenticator'
|
|
||||||
|
|
||||||
// Determine which icon to use based on current theme (you can implement theme detection)
|
|
||||||
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
const iconKey = isDarkMode ? 'icon_dark' : 'icon_light'
|
|
||||||
const authIcon = authInfo && authInfo[iconKey] ? authInfo[iconKey] : null
|
|
||||||
|
|
||||||
// Check if this is the current session credential
|
|
||||||
const isCurrentSession = cred.is_current_session || false
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="credential-item${isCurrentSession ? ' current-session' : ''}">
|
|
||||||
<div class="credential-header">
|
|
||||||
<div class="credential-icon">
|
|
||||||
${authIcon ? `<img src="${authIcon}" alt="${authName}" class="auth-icon" width="32" height="32">` : '<span class="auth-emoji">🔑</span>'}
|
|
||||||
</div>
|
|
||||||
<div class="credential-info">
|
|
||||||
<h4>${authName}</h4>
|
|
||||||
</div>
|
|
||||||
<div class="credential-dates">
|
|
||||||
<span class="date-label">Created:</span>
|
|
||||||
<span class="date-value">${formatHumanReadableDate(cred.created_at)}</span>
|
|
||||||
<span class="date-label">Last used:</span>
|
|
||||||
<span class="date-value">${formatHumanReadableDate(cred.last_used)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="credential-actions">
|
|
||||||
<button onclick="deleteCredential('${cred.credential_id}')"
|
|
||||||
class="btn-delete-credential"
|
|
||||||
${isCurrentSession ? 'disabled title="Cannot delete current session credential"' : ''}>
|
|
||||||
🗑️
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
}).join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to format dates in a human-readable way
|
|
||||||
function formatHumanReadableDate(dateString) {
|
|
||||||
if (!dateString) return 'Never'
|
|
||||||
|
|
||||||
const date = new Date(dateString)
|
|
||||||
const now = new Date()
|
|
||||||
const diffMs = now - date
|
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
|
||||||
|
|
||||||
if (diffHours < 1) {
|
|
||||||
return 'Just now'
|
|
||||||
} else if (diffHours < 24) {
|
|
||||||
return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`
|
|
||||||
} else if (diffDays <= 7) {
|
|
||||||
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`
|
|
||||||
} else {
|
|
||||||
// For dates older than 7 days, show just the date without time
|
|
||||||
return date.toLocaleDateString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout
|
|
||||||
async function logout() {
|
|
||||||
try {
|
|
||||||
await fetch('/api/logout', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Logout error:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUser = null
|
|
||||||
currentCredentials = []
|
|
||||||
aaguidInfo = {}
|
|
||||||
window.location.href = '/auth/login'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is already logged in on page load
|
|
||||||
async function checkExistingSession() {
|
|
||||||
const isLoggedIn = await validateStoredToken()
|
|
||||||
const path = window.location.pathname
|
|
||||||
|
|
||||||
// Protected routes that require authentication
|
|
||||||
const protectedRoutes = ['/auth/profile']
|
|
||||||
|
|
||||||
if (isLoggedIn) {
|
|
||||||
// User is logged in
|
|
||||||
if (path === '/auth/login' || path === '/auth/register' || path === '/') {
|
|
||||||
// Redirect to profile if accessing login/register pages while logged in
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
} else if (path === '/auth/add-device') {
|
|
||||||
// Redirect old add-device route to profile
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
} else if (protectedRoutes.includes(path)) {
|
|
||||||
// Stay on current protected page and load user data
|
|
||||||
if (path === '/auth/profile') {
|
|
||||||
loadUserInfo().then(() => {
|
|
||||||
updateUserInfo()
|
|
||||||
loadCredentials()
|
|
||||||
}).catch(error => {
|
|
||||||
showStatus('profileStatus', `Failed to load user info: ${error.message}`, 'error')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// User is not logged in
|
|
||||||
if (protectedRoutes.includes(path) || path === '/auth/add-device') {
|
|
||||||
// Redirect to login if accessing protected pages without authentication
|
|
||||||
window.location.href = '/auth/login'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the app based on current page
|
|
||||||
function initializeApp() {
|
|
||||||
checkExistingSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form event handlers
|
|
||||||
document.addEventListener('DOMContentLoaded', initializeApp)
|
|
@ -1,43 +0,0 @@
|
|||||||
class AwaitableWebSocket extends WebSocket {
|
|
||||||
#received = []
|
|
||||||
#waiting = []
|
|
||||||
#err = null
|
|
||||||
#opened = false
|
|
||||||
|
|
||||||
constructor(resolve, reject, url, protocols) {
|
|
||||||
super(url, protocols)
|
|
||||||
this.onopen = () => {
|
|
||||||
this.#opened = true
|
|
||||||
resolve(this)
|
|
||||||
}
|
|
||||||
this.onmessage = e => {
|
|
||||||
if (this.#waiting.length) this.#waiting.shift().resolve(e.data)
|
|
||||||
else this.#received.push(e.data)
|
|
||||||
}
|
|
||||||
this.onclose = e => {
|
|
||||||
if (!this.#opened) {
|
|
||||||
reject(new Error(`WebSocket ${this.url} failed to connect, code ${e.code}`))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.#err = e.wasClean
|
|
||||||
? new Error(`Websocket ${this.url} closed ${e.code}`)
|
|
||||||
: new Error(`WebSocket ${this.url} closed with error ${e.code}`)
|
|
||||||
this.#waiting.splice(0).forEach(p => p.reject(this.#err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
recv() {
|
|
||||||
// If we have a message already received, return it immediately
|
|
||||||
if (this.#received.length) return Promise.resolve(this.#received.shift())
|
|
||||||
// Wait for incoming messages, if we have an error, reject immediately
|
|
||||||
if (this.#err) return Promise.reject(this.#err)
|
|
||||||
return new Promise((resolve, reject) => this.#waiting.push({ resolve, reject }))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Construct an async WebSocket with await aWebSocket(url)
|
|
||||||
function aWebSocket(url, protocols) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
new AwaitableWebSocket(resolve, reject, url, protocols)
|
|
||||||
})
|
|
||||||
}
|
|
@ -1,92 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Passkey Authentication</title>
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
|
||||||
<script src="/static/simplewebauthn-browser.min.js"></script>
|
|
||||||
<script src="/static/qrcodejs/qrcode.min.js"></script>
|
|
||||||
<script src="/static/awaitable-websocket.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<!-- Login View -->
|
|
||||||
<div id="loginView" class="view active">
|
|
||||||
<h1>🔐 Passkey Login</h1>
|
|
||||||
<div id="loginStatus"></div>
|
|
||||||
<form id="authenticationForm">
|
|
||||||
<button type="submit" class="btn-primary">Login with Your Device</button>
|
|
||||||
</form>
|
|
||||||
<p class="toggle-link" onclick="showRegisterView()">
|
|
||||||
Don't have an account? Register here
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Register View -->
|
|
||||||
<div id="registerView" class="view">
|
|
||||||
<h1>🔐 Create Account</h1>
|
|
||||||
<div id="registerStatus"></div>
|
|
||||||
<form id="registrationForm">
|
|
||||||
<input type="text" name="username" placeholder="Enter username" required>
|
|
||||||
<button type="submit" class="btn-primary">Register Passkey</button>
|
|
||||||
</form>
|
|
||||||
<p class="toggle-link" onclick="showLoginView()">
|
|
||||||
Already have an account? Login here
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dashboard View -->
|
|
||||||
<div id="dashboardView" class="view">
|
|
||||||
<h1>👋 Welcome!</h1>
|
|
||||||
<div id="userInfo" class="user-info"></div>
|
|
||||||
<div id="dashboardStatus"></div>
|
|
||||||
|
|
||||||
<h2>Your Passkeys</h2>
|
|
||||||
<div id="credentialList" class="credential-list">
|
|
||||||
<p>Loading credentials...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onclick="addNewCredential()" class="btn-primary">
|
|
||||||
Add New Passkey
|
|
||||||
</button>
|
|
||||||
<button onclick="generateAndShowDeviceLink()" class="btn-secondary">
|
|
||||||
Generate Device Link
|
|
||||||
</button>
|
|
||||||
<button onclick="logout()" class="btn-danger">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Device Addition View -->
|
|
||||||
<div id="deviceAdditionView" class="view">
|
|
||||||
<h1>📱 Add Device</h1>
|
|
||||||
<div id="deviceAdditionStatus"></div>
|
|
||||||
|
|
||||||
<div id="deviceLinkSection">
|
|
||||||
<h2>Device Addition Link</h2>
|
|
||||||
<div class="token-info">
|
|
||||||
<p><strong>Share this link to add this account to another device:</strong></p>
|
|
||||||
|
|
||||||
<div class="qr-container">
|
|
||||||
<div id="qrCode" class="qr-code"></div>
|
|
||||||
<p><small>Scan this QR code with your other device</small></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="link-container">
|
|
||||||
<p class="link-text" id="deviceLinkText">Loading...</p>
|
|
||||||
<button class="copy-button" onclick="copyDeviceLink()">Copy Link</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p><small>⚠️ This link expires in 24 hours and can only be used once.</small></p>
|
|
||||||
<p><strong>Human-readable code:</strong> <code id="deviceToken"></code></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onclick="showDashboardView()" class="btn-secondary">
|
|
||||||
Back to Dashboard
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="static/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,28 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Login - Passkey Authentication</title>
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
|
||||||
<script src="/static/simplewebauthn-browser.min.js"></script>
|
|
||||||
<script src="/static/awaitable-websocket.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<!-- Login View -->
|
|
||||||
<div id="loginView" class="view active">
|
|
||||||
<h1>🔐 Passkey Login</h1>
|
|
||||||
<div id="loginStatus"></div>
|
|
||||||
<form id="authenticationForm">
|
|
||||||
<button type="submit" class="btn-primary">Login with Your Device</button>
|
|
||||||
</form>
|
|
||||||
<p class="toggle-link" onclick="window.location.href='/auth/register'">
|
|
||||||
Don't have an account? Register here
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
|
||||||
<script src="/static/util.js"></script>
|
|
||||||
<script src="/static/login.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,38 +0,0 @@
|
|||||||
// Login page specific functionality
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Initialize the app
|
|
||||||
initializeApp()
|
|
||||||
|
|
||||||
// Authentication form handler
|
|
||||||
const authForm = document.getElementById('authenticationForm')
|
|
||||||
if (authForm) {
|
|
||||||
const authSubmitBtn = authForm.querySelector('button[type="submit"]')
|
|
||||||
|
|
||||||
authForm.addEventListener('submit', async (ev) => {
|
|
||||||
ev.preventDefault()
|
|
||||||
authSubmitBtn.disabled = true
|
|
||||||
clearStatus('loginStatus')
|
|
||||||
|
|
||||||
try {
|
|
||||||
showStatus('loginStatus', 'Starting authentication...', 'info')
|
|
||||||
await authenticate()
|
|
||||||
showStatus('loginStatus', 'Authentication successful!', 'success')
|
|
||||||
|
|
||||||
// Navigate to profile
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
}, 1000)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Login error:', err)
|
|
||||||
if (err.name === "NotAllowedError") {
|
|
||||||
showStatus('loginStatus', `Login cancelled`, 'error')
|
|
||||||
} else {
|
|
||||||
showStatus('loginStatus', `Login failed: ${err.message}`, 'error')
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
authSubmitBtn.disabled = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
@ -1,36 +0,0 @@
|
|||||||
/* Profile page dialog styles */
|
|
||||||
|
|
||||||
.container.dialog-open {
|
|
||||||
filter: blur(2px);
|
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dialog styling */
|
|
||||||
#deviceLinkDialog {
|
|
||||||
position: fixed;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
z-index: 999;
|
|
||||||
color: black;
|
|
||||||
background: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
||||||
padding: 2rem;
|
|
||||||
max-width: 500px;
|
|
||||||
width: 90%;
|
|
||||||
max-height: 90vh;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#deviceLinkDialog::backdrop {
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Prevent scrolling when dialog is open */
|
|
||||||
body.dialog-open {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
@ -1,64 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Profile - Passkey Authentication</title>
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
|
||||||
<link rel="stylesheet" href="/static/profile-dialog.css">
|
|
||||||
<script src="/static/simplewebauthn-browser.min.js"></script>
|
|
||||||
<script src="/static/qrcodejs/qrcode.min.js"></script>
|
|
||||||
<script src="/static/awaitable-websocket.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<!-- Profile View -->
|
|
||||||
<div id="profileView" class="view active">
|
|
||||||
<h1>👋 Welcome!</h1>
|
|
||||||
<div id="userInfo" class="user-info"></div>
|
|
||||||
<div id="profileStatus"></div>
|
|
||||||
|
|
||||||
<h2>Your Passkeys</h2>
|
|
||||||
<div id="credentialList" class="credential-list">
|
|
||||||
<p>Loading credentials...</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onclick="addNewCredential()" class="btn-primary">
|
|
||||||
Add New Passkey
|
|
||||||
</button>
|
|
||||||
<button onclick="openDeviceLinkDialog()" class="btn-secondary">
|
|
||||||
Generate Device Link
|
|
||||||
</button>
|
|
||||||
<button onclick="logout()" class="btn-danger">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Device Link Dialog -->
|
|
||||||
<dialog id="deviceLinkDialog">
|
|
||||||
<h1>📱 Add Device</h1>
|
|
||||||
<div id="deviceAdditionStatus"></div>
|
|
||||||
|
|
||||||
<div id="deviceLinkSection">
|
|
||||||
<h2>Device Addition Link</h2>
|
|
||||||
<div class="token-info">
|
|
||||||
<div class="qr-container">
|
|
||||||
<div id="qrCode" class="qr-code"></div>
|
|
||||||
<p><a href="#" id="deviceLinkText"></a></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<strong>Scan the above code and visit the URL on another device.</strong><br>
|
|
||||||
<small>⚠️ Expires in 24 hours and can only be used once.</small>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onclick="closeDeviceLinkDialog()" class="btn-secondary">
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</dialog>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
|
||||||
<script src="/static/profile.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,124 +0,0 @@
|
|||||||
// Profile page specific functionality
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Initialize the app
|
|
||||||
initializeApp()
|
|
||||||
|
|
||||||
// Setup dialog event handlers
|
|
||||||
setupDialogHandlers()
|
|
||||||
})
|
|
||||||
|
|
||||||
// Setup dialog event handlers
|
|
||||||
function setupDialogHandlers() {
|
|
||||||
// Close dialog when clicking outside
|
|
||||||
const dialog = document.getElementById('deviceLinkDialog')
|
|
||||||
if (dialog) {
|
|
||||||
dialog.addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) {
|
|
||||||
closeDeviceLinkDialog()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close dialog when pressing Escape key
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
const dialog = document.getElementById('deviceLinkDialog')
|
|
||||||
if (e.key === 'Escape' && dialog && dialog.open) {
|
|
||||||
closeDeviceLinkDialog()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open device link dialog
|
|
||||||
function openDeviceLinkDialog() {
|
|
||||||
const dialog = document.getElementById('deviceLinkDialog')
|
|
||||||
const container = document.querySelector('.container')
|
|
||||||
const body = document.body
|
|
||||||
|
|
||||||
if (dialog && container && body) {
|
|
||||||
// Add blur and disable effects
|
|
||||||
container.classList.add('dialog-open')
|
|
||||||
body.classList.add('dialog-open')
|
|
||||||
|
|
||||||
dialog.showModal()
|
|
||||||
generateDeviceLink()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close device link dialog
|
|
||||||
function closeDeviceLinkDialog() {
|
|
||||||
const dialog = document.getElementById('deviceLinkDialog')
|
|
||||||
const container = document.querySelector('.container')
|
|
||||||
const body = document.body
|
|
||||||
|
|
||||||
if (dialog && container && body) {
|
|
||||||
// Remove blur and disable effects
|
|
||||||
container.classList.remove('dialog-open')
|
|
||||||
body.classList.remove('dialog-open')
|
|
||||||
|
|
||||||
dialog.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate device link function
|
|
||||||
async function generateDeviceLink() {
|
|
||||||
clearStatus('deviceAdditionStatus')
|
|
||||||
showStatus('deviceAdditionStatus', 'Generating device link...', 'info')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/create-device-link', {
|
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update UI with the link
|
|
||||||
const deviceLinkText = document.getElementById('deviceLinkText')
|
|
||||||
|
|
||||||
if (deviceLinkText) {
|
|
||||||
deviceLinkText.href = result.addition_link
|
|
||||||
deviceLinkText.textContent = result.addition_link.replace(/^[a-z]+:\/\//i, '')
|
|
||||||
|
|
||||||
// Add click event listener for copying the link
|
|
||||||
deviceLinkText.addEventListener('click', function(e) {
|
|
||||||
e.preventDefault() // Prevent navigation
|
|
||||||
navigator.clipboard.writeText(deviceLinkText.href).then(() => {
|
|
||||||
closeDeviceLinkDialog() // Close the dialog
|
|
||||||
showStatus('deviceAdditionStatus', 'Device registration link copied', 'success') // Display status
|
|
||||||
}).catch(() => {
|
|
||||||
showStatus('deviceAdditionStatus', 'Failed to copy device registration link', 'error')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store link globally for copy function
|
|
||||||
window.currentDeviceLink = result.addition_link
|
|
||||||
|
|
||||||
// Generate QR code
|
|
||||||
const qrCodeEl = document.getElementById('qrCode')
|
|
||||||
if (qrCodeEl && typeof QRCode !== 'undefined') {
|
|
||||||
qrCodeEl.innerHTML = ''
|
|
||||||
new QRCode(qrCodeEl, {
|
|
||||||
text: result.addition_link,
|
|
||||||
width: 200,
|
|
||||||
height: 200,
|
|
||||||
colorDark: '#000000',
|
|
||||||
colorLight: '#ffffff',
|
|
||||||
correctLevel: QRCode.CorrectLevel.M
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
clearStatus('deviceAdditionStatus')
|
|
||||||
} catch (error) {
|
|
||||||
showStatus('deviceAdditionStatus', `Failed to generate device link: ${error.message}`, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make functions available globally for onclick handlers
|
|
||||||
window.openDeviceLinkDialog = openDeviceLinkDialog
|
|
||||||
window.closeDeviceLinkDialog = closeDeviceLinkDialog
|
|
4
static/qrcodejs/.gitignore
vendored
4
static/qrcodejs/.gitignore
vendored
@ -1,4 +0,0 @@
|
|||||||
.DS_Store
|
|
||||||
|
|
||||||
.idea
|
|
||||||
.project
|
|
@ -1,14 +0,0 @@
|
|||||||
The MIT License (MIT)
|
|
||||||
---------------------
|
|
||||||
Copyright (c) 2012 davidshimjs
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge,
|
|
||||||
to any person obtaining a copy of this software and associated documentation files (the "Software"),
|
|
||||||
to deal in the Software without restriction,
|
|
||||||
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
||||||
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
|
|
||||||
subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@ -1,46 +0,0 @@
|
|||||||
# QRCode.js
|
|
||||||
QRCode.js is javascript library for making QRCode. QRCode.js supports Cross-browser with HTML5 Canvas and table tag in DOM.
|
|
||||||
QRCode.js has no dependencies.
|
|
||||||
|
|
||||||
## Basic Usages
|
|
||||||
```
|
|
||||||
<div id="qrcode"></div>
|
|
||||||
<script type="text/javascript">
|
|
||||||
new QRCode(document.getElementById("qrcode"), "http://jindo.dev.naver.com/collie");
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
or with some options
|
|
||||||
|
|
||||||
```
|
|
||||||
<div id="qrcode"></div>
|
|
||||||
<script type="text/javascript">
|
|
||||||
var qrcode = new QRCode(document.getElementById("qrcode"), {
|
|
||||||
text: "http://jindo.dev.naver.com/collie",
|
|
||||||
width: 128,
|
|
||||||
height: 128,
|
|
||||||
colorDark : "#000000",
|
|
||||||
colorLight : "#ffffff",
|
|
||||||
correctLevel : QRCode.CorrectLevel.H
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
```
|
|
||||||
|
|
||||||
and you can use some methods
|
|
||||||
|
|
||||||
```
|
|
||||||
qrcode.clear(); // clear the code.
|
|
||||||
qrcode.makeCode("http://naver.com"); // make another code.
|
|
||||||
```
|
|
||||||
|
|
||||||
## Browser Compatibility
|
|
||||||
IE6~10, Chrome, Firefox, Safari, Opera, Mobile Safari, Android, Windows Mobile, ETC.
|
|
||||||
|
|
||||||
## License
|
|
||||||
MIT License
|
|
||||||
|
|
||||||
## Contact
|
|
||||||
twitter @davidshimjs
|
|
||||||
|
|
||||||
[](https://bitdeli.com/free "Bitdeli Badge")
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "qrcode.js",
|
|
||||||
"version": "0.0.1",
|
|
||||||
"homepage": "https://github.com/davidshimjs/qrcodejs",
|
|
||||||
"authors": [
|
|
||||||
"Sangmin Shim", "Sangmin Shim <ssm0123@gmail.com> (http://jaguarjs.com)"
|
|
||||||
],
|
|
||||||
"description": "Cross-browser QRCode generator for javascript",
|
|
||||||
"main": "qrcode.js",
|
|
||||||
"ignore": [
|
|
||||||
"bower_components",
|
|
||||||
"node_modules",
|
|
||||||
"index.html",
|
|
||||||
"index.svg",
|
|
||||||
"jquery.min.js",
|
|
||||||
"qrcode.min.js"
|
|
||||||
]
|
|
||||||
}
|
|
@ -1,47 +0,0 @@
|
|||||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ko" lang="ko">
|
|
||||||
<head>
|
|
||||||
<title>Cross-Browser QRCode generator for Javascript</title>
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no" />
|
|
||||||
<script type="text/javascript" src="jquery.min.js"></script>
|
|
||||||
<script type="text/javascript" src="qrcode.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<input id="text" type="text" value="http://jindo.dev.naver.com/collie" style="width:80%" />
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
|
||||||
<g id="qrcode"/>
|
|
||||||
</svg>
|
|
||||||
<script type="text/javascript">
|
|
||||||
var qrcode = new QRCode(document.getElementById("qrcode"), {
|
|
||||||
width : 100,
|
|
||||||
height : 100,
|
|
||||||
useSVG: true
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeCode () {
|
|
||||||
var elText = document.getElementById("text");
|
|
||||||
|
|
||||||
if (!elText.value) {
|
|
||||||
alert("Input a text");
|
|
||||||
elText.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
qrcode.makeCode(elText.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
makeCode();
|
|
||||||
|
|
||||||
$("#text").
|
|
||||||
on("blur", function () {
|
|
||||||
makeCode();
|
|
||||||
}).
|
|
||||||
on("keydown", function (e) {
|
|
||||||
if (e.keyCode == 13) {
|
|
||||||
makeCode();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,44 +0,0 @@
|
|||||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
||||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ko" lang="ko">
|
|
||||||
<head>
|
|
||||||
<title>Cross-Browser QRCode generator for Javascript</title>
|
|
||||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no" />
|
|
||||||
<script type="text/javascript" src="jquery.min.js"></script>
|
|
||||||
<script type="text/javascript" src="qrcode.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<input id="text" type="text" value="http://jindo.dev.naver.com/collie" style="width:80%" /><br />
|
|
||||||
<div id="qrcode" style="width:100px; height:100px; margin-top:15px;"></div>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
var qrcode = new QRCode(document.getElementById("qrcode"), {
|
|
||||||
width : 100,
|
|
||||||
height : 100
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeCode () {
|
|
||||||
var elText = document.getElementById("text");
|
|
||||||
|
|
||||||
if (!elText.value) {
|
|
||||||
alert("Input a text");
|
|
||||||
elText.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
qrcode.makeCode(elText.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
makeCode();
|
|
||||||
|
|
||||||
$("#text").
|
|
||||||
on("blur", function () {
|
|
||||||
makeCode();
|
|
||||||
}).
|
|
||||||
on("keydown", function (e) {
|
|
||||||
if (e.keyCode == 13) {
|
|
||||||
makeCode();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
@ -1,37 +0,0 @@
|
|||||||
<?xml version="1.0" standalone="yes"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="-50 0 200 100">
|
|
||||||
<g id="qrcode"/>
|
|
||||||
<foreignObject x="-50" y="0" width="100" height="100">
|
|
||||||
<body xmlns="http://www.w3.org/1999/xhtml" style="padding:0; margin:0">
|
|
||||||
<div style="padding:inherit; margin:inherit; height:100%">
|
|
||||||
<textarea id="text" style="height:100%; width:100%; position:absolute; margin:inherit; padding:inherit">james</textarea>
|
|
||||||
</div>
|
|
||||||
<script type="application/ecmascript" src="qrcode.js"></script>
|
|
||||||
<script type="application/ecmascript">
|
|
||||||
var elem = document.getElementById("qrcode");
|
|
||||||
var qrcode = new QRCode(elem, {
|
|
||||||
width : 100,
|
|
||||||
height : 100
|
|
||||||
});
|
|
||||||
|
|
||||||
function makeCode () {
|
|
||||||
var elText = document.getElementById("text");
|
|
||||||
|
|
||||||
if (elText.value === "") {
|
|
||||||
//alert("Input a text");
|
|
||||||
//elText.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
qrcode.makeCode(elText.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
makeCode();
|
|
||||||
|
|
||||||
document.getElementById("text").onkeyup = function (e) {
|
|
||||||
makeCode();
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</foreignObject>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 1.1 KiB |
2
static/qrcodejs/jquery.min.js
vendored
2
static/qrcodejs/jquery.min.js
vendored
File diff suppressed because one or more lines are too long
@ -1,614 +0,0 @@
|
|||||||
/**
|
|
||||||
* @fileoverview
|
|
||||||
* - Using the 'QRCode for Javascript library'
|
|
||||||
* - Fixed dataset of 'QRCode for Javascript library' for support full-spec.
|
|
||||||
* - this library has no dependencies.
|
|
||||||
*
|
|
||||||
* @author davidshimjs
|
|
||||||
* @see <a href="http://www.d-project.com/" target="_blank">http://www.d-project.com/</a>
|
|
||||||
* @see <a href="http://jeromeetienne.github.com/jquery-qrcode/" target="_blank">http://jeromeetienne.github.com/jquery-qrcode/</a>
|
|
||||||
*/
|
|
||||||
var QRCode;
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
//---------------------------------------------------------------------
|
|
||||||
// QRCode for JavaScript
|
|
||||||
//
|
|
||||||
// Copyright (c) 2009 Kazuhiko Arase
|
|
||||||
//
|
|
||||||
// URL: http://www.d-project.com/
|
|
||||||
//
|
|
||||||
// Licensed under the MIT license:
|
|
||||||
// http://www.opensource.org/licenses/mit-license.php
|
|
||||||
//
|
|
||||||
// The word "QR Code" is registered trademark of
|
|
||||||
// DENSO WAVE INCORPORATED
|
|
||||||
// http://www.denso-wave.com/qrcode/faqpatent-e.html
|
|
||||||
//
|
|
||||||
//---------------------------------------------------------------------
|
|
||||||
function QR8bitByte(data) {
|
|
||||||
this.mode = QRMode.MODE_8BIT_BYTE;
|
|
||||||
this.data = data;
|
|
||||||
this.parsedData = [];
|
|
||||||
|
|
||||||
// Added to support UTF-8 Characters
|
|
||||||
for (var i = 0, l = this.data.length; i < l; i++) {
|
|
||||||
var byteArray = [];
|
|
||||||
var code = this.data.charCodeAt(i);
|
|
||||||
|
|
||||||
if (code > 0x10000) {
|
|
||||||
byteArray[0] = 0xF0 | ((code & 0x1C0000) >>> 18);
|
|
||||||
byteArray[1] = 0x80 | ((code & 0x3F000) >>> 12);
|
|
||||||
byteArray[2] = 0x80 | ((code & 0xFC0) >>> 6);
|
|
||||||
byteArray[3] = 0x80 | (code & 0x3F);
|
|
||||||
} else if (code > 0x800) {
|
|
||||||
byteArray[0] = 0xE0 | ((code & 0xF000) >>> 12);
|
|
||||||
byteArray[1] = 0x80 | ((code & 0xFC0) >>> 6);
|
|
||||||
byteArray[2] = 0x80 | (code & 0x3F);
|
|
||||||
} else if (code > 0x80) {
|
|
||||||
byteArray[0] = 0xC0 | ((code & 0x7C0) >>> 6);
|
|
||||||
byteArray[1] = 0x80 | (code & 0x3F);
|
|
||||||
} else {
|
|
||||||
byteArray[0] = code;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.parsedData.push(byteArray);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.parsedData = Array.prototype.concat.apply([], this.parsedData);
|
|
||||||
|
|
||||||
if (this.parsedData.length != this.data.length) {
|
|
||||||
this.parsedData.unshift(191);
|
|
||||||
this.parsedData.unshift(187);
|
|
||||||
this.parsedData.unshift(239);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
QR8bitByte.prototype = {
|
|
||||||
getLength: function (buffer) {
|
|
||||||
return this.parsedData.length;
|
|
||||||
},
|
|
||||||
write: function (buffer) {
|
|
||||||
for (var i = 0, l = this.parsedData.length; i < l; i++) {
|
|
||||||
buffer.put(this.parsedData[i], 8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function QRCodeModel(typeNumber, errorCorrectLevel) {
|
|
||||||
this.typeNumber = typeNumber;
|
|
||||||
this.errorCorrectLevel = errorCorrectLevel;
|
|
||||||
this.modules = null;
|
|
||||||
this.moduleCount = 0;
|
|
||||||
this.dataCache = null;
|
|
||||||
this.dataList = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
QRCodeModel.prototype={addData:function(data){var newData=new QR8bitByte(data);this.dataList.push(newData);this.dataCache=null;},isDark:function(row,col){if(row<0||this.moduleCount<=row||col<0||this.moduleCount<=col){throw new Error(row+","+col);}
|
|
||||||
return this.modules[row][col];},getModuleCount:function(){return this.moduleCount;},make:function(){this.makeImpl(false,this.getBestMaskPattern());},makeImpl:function(test,maskPattern){this.moduleCount=this.typeNumber*4+17;this.modules=new Array(this.moduleCount);for(var row=0;row<this.moduleCount;row++){this.modules[row]=new Array(this.moduleCount);for(var col=0;col<this.moduleCount;col++){this.modules[row][col]=null;}}
|
|
||||||
this.setupPositionProbePattern(0,0);this.setupPositionProbePattern(this.moduleCount-7,0);this.setupPositionProbePattern(0,this.moduleCount-7);this.setupPositionAdjustPattern();this.setupTimingPattern();this.setupTypeInfo(test,maskPattern);if(this.typeNumber>=7){this.setupTypeNumber(test);}
|
|
||||||
if(this.dataCache==null){this.dataCache=QRCodeModel.createData(this.typeNumber,this.errorCorrectLevel,this.dataList);}
|
|
||||||
this.mapData(this.dataCache,maskPattern);},setupPositionProbePattern:function(row,col){for(var r=-1;r<=7;r++){if(row+r<=-1||this.moduleCount<=row+r)continue;for(var c=-1;c<=7;c++){if(col+c<=-1||this.moduleCount<=col+c)continue;if((0<=r&&r<=6&&(c==0||c==6))||(0<=c&&c<=6&&(r==0||r==6))||(2<=r&&r<=4&&2<=c&&c<=4)){this.modules[row+r][col+c]=true;}else{this.modules[row+r][col+c]=false;}}}},getBestMaskPattern:function(){var minLostPoint=0;var pattern=0;for(var i=0;i<8;i++){this.makeImpl(true,i);var lostPoint=QRUtil.getLostPoint(this);if(i==0||minLostPoint>lostPoint){minLostPoint=lostPoint;pattern=i;}}
|
|
||||||
return pattern;},createMovieClip:function(target_mc,instance_name,depth){var qr_mc=target_mc.createEmptyMovieClip(instance_name,depth);var cs=1;this.make();for(var row=0;row<this.modules.length;row++){var y=row*cs;for(var col=0;col<this.modules[row].length;col++){var x=col*cs;var dark=this.modules[row][col];if(dark){qr_mc.beginFill(0,100);qr_mc.moveTo(x,y);qr_mc.lineTo(x+cs,y);qr_mc.lineTo(x+cs,y+cs);qr_mc.lineTo(x,y+cs);qr_mc.endFill();}}}
|
|
||||||
return qr_mc;},setupTimingPattern:function(){for(var r=8;r<this.moduleCount-8;r++){if(this.modules[r][6]!=null){continue;}
|
|
||||||
this.modules[r][6]=(r%2==0);}
|
|
||||||
for(var c=8;c<this.moduleCount-8;c++){if(this.modules[6][c]!=null){continue;}
|
|
||||||
this.modules[6][c]=(c%2==0);}},setupPositionAdjustPattern:function(){var pos=QRUtil.getPatternPosition(this.typeNumber);for(var i=0;i<pos.length;i++){for(var j=0;j<pos.length;j++){var row=pos[i];var col=pos[j];if(this.modules[row][col]!=null){continue;}
|
|
||||||
for(var r=-2;r<=2;r++){for(var c=-2;c<=2;c++){if(r==-2||r==2||c==-2||c==2||(r==0&&c==0)){this.modules[row+r][col+c]=true;}else{this.modules[row+r][col+c]=false;}}}}}},setupTypeNumber:function(test){var bits=QRUtil.getBCHTypeNumber(this.typeNumber);for(var i=0;i<18;i++){var mod=(!test&&((bits>>i)&1)==1);this.modules[Math.floor(i/3)][i%3+this.moduleCount-8-3]=mod;}
|
|
||||||
for(var i=0;i<18;i++){var mod=(!test&&((bits>>i)&1)==1);this.modules[i%3+this.moduleCount-8-3][Math.floor(i/3)]=mod;}},setupTypeInfo:function(test,maskPattern){var data=(this.errorCorrectLevel<<3)|maskPattern;var bits=QRUtil.getBCHTypeInfo(data);for(var i=0;i<15;i++){var mod=(!test&&((bits>>i)&1)==1);if(i<6){this.modules[i][8]=mod;}else if(i<8){this.modules[i+1][8]=mod;}else{this.modules[this.moduleCount-15+i][8]=mod;}}
|
|
||||||
for(var i=0;i<15;i++){var mod=(!test&&((bits>>i)&1)==1);if(i<8){this.modules[8][this.moduleCount-i-1]=mod;}else if(i<9){this.modules[8][15-i-1+1]=mod;}else{this.modules[8][15-i-1]=mod;}}
|
|
||||||
this.modules[this.moduleCount-8][8]=(!test);},mapData:function(data,maskPattern){var inc=-1;var row=this.moduleCount-1;var bitIndex=7;var byteIndex=0;for(var col=this.moduleCount-1;col>0;col-=2){if(col==6)col--;while(true){for(var c=0;c<2;c++){if(this.modules[row][col-c]==null){var dark=false;if(byteIndex<data.length){dark=(((data[byteIndex]>>>bitIndex)&1)==1);}
|
|
||||||
var mask=QRUtil.getMask(maskPattern,row,col-c);if(mask){dark=!dark;}
|
|
||||||
this.modules[row][col-c]=dark;bitIndex--;if(bitIndex==-1){byteIndex++;bitIndex=7;}}}
|
|
||||||
row+=inc;if(row<0||this.moduleCount<=row){row-=inc;inc=-inc;break;}}}}};QRCodeModel.PAD0=0xEC;QRCodeModel.PAD1=0x11;QRCodeModel.createData=function(typeNumber,errorCorrectLevel,dataList){var rsBlocks=QRRSBlock.getRSBlocks(typeNumber,errorCorrectLevel);var buffer=new QRBitBuffer();for(var i=0;i<dataList.length;i++){var data=dataList[i];buffer.put(data.mode,4);buffer.put(data.getLength(),QRUtil.getLengthInBits(data.mode,typeNumber));data.write(buffer);}
|
|
||||||
var totalDataCount=0;for(var i=0;i<rsBlocks.length;i++){totalDataCount+=rsBlocks[i].dataCount;}
|
|
||||||
if(buffer.getLengthInBits()>totalDataCount*8){throw new Error("code length overflow. ("
|
|
||||||
+buffer.getLengthInBits()
|
|
||||||
+">"
|
|
||||||
+totalDataCount*8
|
|
||||||
+")");}
|
|
||||||
if(buffer.getLengthInBits()+4<=totalDataCount*8){buffer.put(0,4);}
|
|
||||||
while(buffer.getLengthInBits()%8!=0){buffer.putBit(false);}
|
|
||||||
while(true){if(buffer.getLengthInBits()>=totalDataCount*8){break;}
|
|
||||||
buffer.put(QRCodeModel.PAD0,8);if(buffer.getLengthInBits()>=totalDataCount*8){break;}
|
|
||||||
buffer.put(QRCodeModel.PAD1,8);}
|
|
||||||
return QRCodeModel.createBytes(buffer,rsBlocks);};QRCodeModel.createBytes=function(buffer,rsBlocks){var offset=0;var maxDcCount=0;var maxEcCount=0;var dcdata=new Array(rsBlocks.length);var ecdata=new Array(rsBlocks.length);for(var r=0;r<rsBlocks.length;r++){var dcCount=rsBlocks[r].dataCount;var ecCount=rsBlocks[r].totalCount-dcCount;maxDcCount=Math.max(maxDcCount,dcCount);maxEcCount=Math.max(maxEcCount,ecCount);dcdata[r]=new Array(dcCount);for(var i=0;i<dcdata[r].length;i++){dcdata[r][i]=0xff&buffer.buffer[i+offset];}
|
|
||||||
offset+=dcCount;var rsPoly=QRUtil.getErrorCorrectPolynomial(ecCount);var rawPoly=new QRPolynomial(dcdata[r],rsPoly.getLength()-1);var modPoly=rawPoly.mod(rsPoly);ecdata[r]=new Array(rsPoly.getLength()-1);for(var i=0;i<ecdata[r].length;i++){var modIndex=i+modPoly.getLength()-ecdata[r].length;ecdata[r][i]=(modIndex>=0)?modPoly.get(modIndex):0;}}
|
|
||||||
var totalCodeCount=0;for(var i=0;i<rsBlocks.length;i++){totalCodeCount+=rsBlocks[i].totalCount;}
|
|
||||||
var data=new Array(totalCodeCount);var index=0;for(var i=0;i<maxDcCount;i++){for(var r=0;r<rsBlocks.length;r++){if(i<dcdata[r].length){data[index++]=dcdata[r][i];}}}
|
|
||||||
for(var i=0;i<maxEcCount;i++){for(var r=0;r<rsBlocks.length;r++){if(i<ecdata[r].length){data[index++]=ecdata[r][i];}}}
|
|
||||||
return data;};var QRMode={MODE_NUMBER:1<<0,MODE_ALPHA_NUM:1<<1,MODE_8BIT_BYTE:1<<2,MODE_KANJI:1<<3};var QRErrorCorrectLevel={L:1,M:0,Q:3,H:2};var QRMaskPattern={PATTERN000:0,PATTERN001:1,PATTERN010:2,PATTERN011:3,PATTERN100:4,PATTERN101:5,PATTERN110:6,PATTERN111:7};var QRUtil={PATTERN_POSITION_TABLE:[[],[6,18],[6,22],[6,26],[6,30],[6,34],[6,22,38],[6,24,42],[6,26,46],[6,28,50],[6,30,54],[6,32,58],[6,34,62],[6,26,46,66],[6,26,48,70],[6,26,50,74],[6,30,54,78],[6,30,56,82],[6,30,58,86],[6,34,62,90],[6,28,50,72,94],[6,26,50,74,98],[6,30,54,78,102],[6,28,54,80,106],[6,32,58,84,110],[6,30,58,86,114],[6,34,62,90,118],[6,26,50,74,98,122],[6,30,54,78,102,126],[6,26,52,78,104,130],[6,30,56,82,108,134],[6,34,60,86,112,138],[6,30,58,86,114,142],[6,34,62,90,118,146],[6,30,54,78,102,126,150],[6,24,50,76,102,128,154],[6,28,54,80,106,132,158],[6,32,58,84,110,136,162],[6,26,54,82,110,138,166],[6,30,58,86,114,142,170]],G15:(1<<10)|(1<<8)|(1<<5)|(1<<4)|(1<<2)|(1<<1)|(1<<0),G18:(1<<12)|(1<<11)|(1<<10)|(1<<9)|(1<<8)|(1<<5)|(1<<2)|(1<<0),G15_MASK:(1<<14)|(1<<12)|(1<<10)|(1<<4)|(1<<1),getBCHTypeInfo:function(data){var d=data<<10;while(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G15)>=0){d^=(QRUtil.G15<<(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G15)));}
|
|
||||||
return((data<<10)|d)^QRUtil.G15_MASK;},getBCHTypeNumber:function(data){var d=data<<12;while(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G18)>=0){d^=(QRUtil.G18<<(QRUtil.getBCHDigit(d)-QRUtil.getBCHDigit(QRUtil.G18)));}
|
|
||||||
return(data<<12)|d;},getBCHDigit:function(data){var digit=0;while(data!=0){digit++;data>>>=1;}
|
|
||||||
return digit;},getPatternPosition:function(typeNumber){return QRUtil.PATTERN_POSITION_TABLE[typeNumber-1];},getMask:function(maskPattern,i,j){switch(maskPattern){case QRMaskPattern.PATTERN000:return(i+j)%2==0;case QRMaskPattern.PATTERN001:return i%2==0;case QRMaskPattern.PATTERN010:return j%3==0;case QRMaskPattern.PATTERN011:return(i+j)%3==0;case QRMaskPattern.PATTERN100:return(Math.floor(i/2)+Math.floor(j/3))%2==0;case QRMaskPattern.PATTERN101:return(i*j)%2+(i*j)%3==0;case QRMaskPattern.PATTERN110:return((i*j)%2+(i*j)%3)%2==0;case QRMaskPattern.PATTERN111:return((i*j)%3+(i+j)%2)%2==0;default:throw new Error("bad maskPattern:"+maskPattern);}},getErrorCorrectPolynomial:function(errorCorrectLength){var a=new QRPolynomial([1],0);for(var i=0;i<errorCorrectLength;i++){a=a.multiply(new QRPolynomial([1,QRMath.gexp(i)],0));}
|
|
||||||
return a;},getLengthInBits:function(mode,type){if(1<=type&&type<10){switch(mode){case QRMode.MODE_NUMBER:return 10;case QRMode.MODE_ALPHA_NUM:return 9;case QRMode.MODE_8BIT_BYTE:return 8;case QRMode.MODE_KANJI:return 8;default:throw new Error("mode:"+mode);}}else if(type<27){switch(mode){case QRMode.MODE_NUMBER:return 12;case QRMode.MODE_ALPHA_NUM:return 11;case QRMode.MODE_8BIT_BYTE:return 16;case QRMode.MODE_KANJI:return 10;default:throw new Error("mode:"+mode);}}else if(type<41){switch(mode){case QRMode.MODE_NUMBER:return 14;case QRMode.MODE_ALPHA_NUM:return 13;case QRMode.MODE_8BIT_BYTE:return 16;case QRMode.MODE_KANJI:return 12;default:throw new Error("mode:"+mode);}}else{throw new Error("type:"+type);}},getLostPoint:function(qrCode){var moduleCount=qrCode.getModuleCount();var lostPoint=0;for(var row=0;row<moduleCount;row++){for(var col=0;col<moduleCount;col++){var sameCount=0;var dark=qrCode.isDark(row,col);for(var r=-1;r<=1;r++){if(row+r<0||moduleCount<=row+r){continue;}
|
|
||||||
for(var c=-1;c<=1;c++){if(col+c<0||moduleCount<=col+c){continue;}
|
|
||||||
if(r==0&&c==0){continue;}
|
|
||||||
if(dark==qrCode.isDark(row+r,col+c)){sameCount++;}}}
|
|
||||||
if(sameCount>5){lostPoint+=(3+sameCount-5);}}}
|
|
||||||
for(var row=0;row<moduleCount-1;row++){for(var col=0;col<moduleCount-1;col++){var count=0;if(qrCode.isDark(row,col))count++;if(qrCode.isDark(row+1,col))count++;if(qrCode.isDark(row,col+1))count++;if(qrCode.isDark(row+1,col+1))count++;if(count==0||count==4){lostPoint+=3;}}}
|
|
||||||
for(var row=0;row<moduleCount;row++){for(var col=0;col<moduleCount-6;col++){if(qrCode.isDark(row,col)&&!qrCode.isDark(row,col+1)&&qrCode.isDark(row,col+2)&&qrCode.isDark(row,col+3)&&qrCode.isDark(row,col+4)&&!qrCode.isDark(row,col+5)&&qrCode.isDark(row,col+6)){lostPoint+=40;}}}
|
|
||||||
for(var col=0;col<moduleCount;col++){for(var row=0;row<moduleCount-6;row++){if(qrCode.isDark(row,col)&&!qrCode.isDark(row+1,col)&&qrCode.isDark(row+2,col)&&qrCode.isDark(row+3,col)&&qrCode.isDark(row+4,col)&&!qrCode.isDark(row+5,col)&&qrCode.isDark(row+6,col)){lostPoint+=40;}}}
|
|
||||||
var darkCount=0;for(var col=0;col<moduleCount;col++){for(var row=0;row<moduleCount;row++){if(qrCode.isDark(row,col)){darkCount++;}}}
|
|
||||||
var ratio=Math.abs(100*darkCount/moduleCount/moduleCount-50)/5;lostPoint+=ratio*10;return lostPoint;}};var QRMath={glog:function(n){if(n<1){throw new Error("glog("+n+")");}
|
|
||||||
return QRMath.LOG_TABLE[n];},gexp:function(n){while(n<0){n+=255;}
|
|
||||||
while(n>=256){n-=255;}
|
|
||||||
return QRMath.EXP_TABLE[n];},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)};for(var i=0;i<8;i++){QRMath.EXP_TABLE[i]=1<<i;}
|
|
||||||
for(var i=8;i<256;i++){QRMath.EXP_TABLE[i]=QRMath.EXP_TABLE[i-4]^QRMath.EXP_TABLE[i-5]^QRMath.EXP_TABLE[i-6]^QRMath.EXP_TABLE[i-8];}
|
|
||||||
for(var i=0;i<255;i++){QRMath.LOG_TABLE[QRMath.EXP_TABLE[i]]=i;}
|
|
||||||
function QRPolynomial(num,shift){if(num.length==undefined){throw new Error(num.length+"/"+shift);}
|
|
||||||
var offset=0;while(offset<num.length&&num[offset]==0){offset++;}
|
|
||||||
this.num=new Array(num.length-offset+shift);for(var i=0;i<num.length-offset;i++){this.num[i]=num[i+offset];}}
|
|
||||||
QRPolynomial.prototype={get:function(index){return this.num[index];},getLength:function(){return this.num.length;},multiply:function(e){var num=new Array(this.getLength()+e.getLength()-1);for(var i=0;i<this.getLength();i++){for(var j=0;j<e.getLength();j++){num[i+j]^=QRMath.gexp(QRMath.glog(this.get(i))+QRMath.glog(e.get(j)));}}
|
|
||||||
return new QRPolynomial(num,0);},mod:function(e){if(this.getLength()-e.getLength()<0){return this;}
|
|
||||||
var ratio=QRMath.glog(this.get(0))-QRMath.glog(e.get(0));var num=new Array(this.getLength());for(var i=0;i<this.getLength();i++){num[i]=this.get(i);}
|
|
||||||
for(var i=0;i<e.getLength();i++){num[i]^=QRMath.gexp(QRMath.glog(e.get(i))+ratio);}
|
|
||||||
return new QRPolynomial(num,0).mod(e);}};function QRRSBlock(totalCount,dataCount){this.totalCount=totalCount;this.dataCount=dataCount;}
|
|
||||||
QRRSBlock.RS_BLOCK_TABLE=[[1,26,19],[1,26,16],[1,26,13],[1,26,9],[1,44,34],[1,44,28],[1,44,22],[1,44,16],[1,70,55],[1,70,44],[2,35,17],[2,35,13],[1,100,80],[2,50,32],[2,50,24],[4,25,9],[1,134,108],[2,67,43],[2,33,15,2,34,16],[2,33,11,2,34,12],[2,86,68],[4,43,27],[4,43,19],[4,43,15],[2,98,78],[4,49,31],[2,32,14,4,33,15],[4,39,13,1,40,14],[2,121,97],[2,60,38,2,61,39],[4,40,18,2,41,19],[4,40,14,2,41,15],[2,146,116],[3,58,36,2,59,37],[4,36,16,4,37,17],[4,36,12,4,37,13],[2,86,68,2,87,69],[4,69,43,1,70,44],[6,43,19,2,44,20],[6,43,15,2,44,16],[4,101,81],[1,80,50,4,81,51],[4,50,22,4,51,23],[3,36,12,8,37,13],[2,116,92,2,117,93],[6,58,36,2,59,37],[4,46,20,6,47,21],[7,42,14,4,43,15],[4,133,107],[8,59,37,1,60,38],[8,44,20,4,45,21],[12,33,11,4,34,12],[3,145,115,1,146,116],[4,64,40,5,65,41],[11,36,16,5,37,17],[11,36,12,5,37,13],[5,109,87,1,110,88],[5,65,41,5,66,42],[5,54,24,7,55,25],[11,36,12],[5,122,98,1,123,99],[7,73,45,3,74,46],[15,43,19,2,44,20],[3,45,15,13,46,16],[1,135,107,5,136,108],[10,74,46,1,75,47],[1,50,22,15,51,23],[2,42,14,17,43,15],[5,150,120,1,151,121],[9,69,43,4,70,44],[17,50,22,1,51,23],[2,42,14,19,43,15],[3,141,113,4,142,114],[3,70,44,11,71,45],[17,47,21,4,48,22],[9,39,13,16,40,14],[3,135,107,5,136,108],[3,67,41,13,68,42],[15,54,24,5,55,25],[15,43,15,10,44,16],[4,144,116,4,145,117],[17,68,42],[17,50,22,6,51,23],[19,46,16,6,47,17],[2,139,111,7,140,112],[17,74,46],[7,54,24,16,55,25],[34,37,13],[4,151,121,5,152,122],[4,75,47,14,76,48],[11,54,24,14,55,25],[16,45,15,14,46,16],[6,147,117,4,148,118],[6,73,45,14,74,46],[11,54,24,16,55,25],[30,46,16,2,47,17],[8,132,106,4,133,107],[8,75,47,13,76,48],[7,54,24,22,55,25],[22,45,15,13,46,16],[10,142,114,2,143,115],[19,74,46,4,75,47],[28,50,22,6,51,23],[33,46,16,4,47,17],[8,152,122,4,153,123],[22,73,45,3,74,46],[8,53,23,26,54,24],[12,45,15,28,46,16],[3,147,117,10,148,118],[3,73,45,23,74,46],[4,54,24,31,55,25],[11,45,15,31,46,16],[7,146,116,7,147,117],[21,73,45,7,74,46],[1,53,23,37,54,24],[19,45,15,26,46,16],[5,145,115,10,146,116],[19,75,47,10,76,48],[15,54,24,25,55,25],[23,45,15,25,46,16],[13,145,115,3,146,116],[2,74,46,29,75,47],[42,54,24,1,55,25],[23,45,15,28,46,16],[17,145,115],[10,74,46,23,75,47],[10,54,24,35,55,25],[19,45,15,35,46,16],[17,145,115,1,146,116],[14,74,46,21,75,47],[29,54,24,19,55,25],[11,45,15,46,46,16],[13,145,115,6,146,116],[14,74,46,23,75,47],[44,54,24,7,55,25],[59,46,16,1,47,17],[12,151,121,7,152,122],[12,75,47,26,76,48],[39,54,24,14,55,25],[22,45,15,41,46,16],[6,151,121,14,152,122],[6,75,47,34,76,48],[46,54,24,10,55,25],[2,45,15,64,46,16],[17,152,122,4,153,123],[29,74,46,14,75,47],[49,54,24,10,55,25],[24,45,15,46,46,16],[4,152,122,18,153,123],[13,74,46,32,75,47],[48,54,24,14,55,25],[42,45,15,32,46,16],[20,147,117,4,148,118],[40,75,47,7,76,48],[43,54,24,22,55,25],[10,45,15,67,46,16],[19,148,118,6,149,119],[18,75,47,31,76,48],[34,54,24,34,55,25],[20,45,15,61,46,16]];QRRSBlock.getRSBlocks=function(typeNumber,errorCorrectLevel){var rsBlock=QRRSBlock.getRsBlockTable(typeNumber,errorCorrectLevel);if(rsBlock==undefined){throw new Error("bad rs block @ typeNumber:"+typeNumber+"/errorCorrectLevel:"+errorCorrectLevel);}
|
|
||||||
var length=rsBlock.length/3;var list=[];for(var i=0;i<length;i++){var count=rsBlock[i*3+0];var totalCount=rsBlock[i*3+1];var dataCount=rsBlock[i*3+2];for(var j=0;j<count;j++){list.push(new QRRSBlock(totalCount,dataCount));}}
|
|
||||||
return list;};QRRSBlock.getRsBlockTable=function(typeNumber,errorCorrectLevel){switch(errorCorrectLevel){case QRErrorCorrectLevel.L:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+0];case QRErrorCorrectLevel.M:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+1];case QRErrorCorrectLevel.Q:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+2];case QRErrorCorrectLevel.H:return QRRSBlock.RS_BLOCK_TABLE[(typeNumber-1)*4+3];default:return undefined;}};function QRBitBuffer(){this.buffer=[];this.length=0;}
|
|
||||||
QRBitBuffer.prototype={get:function(index){var bufIndex=Math.floor(index/8);return((this.buffer[bufIndex]>>>(7-index%8))&1)==1;},put:function(num,length){for(var i=0;i<length;i++){this.putBit(((num>>>(length-i-1))&1)==1);}},getLengthInBits:function(){return this.length;},putBit:function(bit){var bufIndex=Math.floor(this.length/8);if(this.buffer.length<=bufIndex){this.buffer.push(0);}
|
|
||||||
if(bit){this.buffer[bufIndex]|=(0x80>>>(this.length%8));}
|
|
||||||
this.length++;}};var QRCodeLimitLength=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]];
|
|
||||||
|
|
||||||
function _isSupportCanvas() {
|
|
||||||
return typeof CanvasRenderingContext2D != "undefined";
|
|
||||||
}
|
|
||||||
|
|
||||||
// android 2.x doesn't support Data-URI spec
|
|
||||||
function _getAndroid() {
|
|
||||||
var android = false;
|
|
||||||
var sAgent = navigator.userAgent;
|
|
||||||
|
|
||||||
if (/android/i.test(sAgent)) { // android
|
|
||||||
android = true;
|
|
||||||
var aMat = sAgent.toString().match(/android ([0-9]\.[0-9])/i);
|
|
||||||
|
|
||||||
if (aMat && aMat[1]) {
|
|
||||||
android = parseFloat(aMat[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return android;
|
|
||||||
}
|
|
||||||
|
|
||||||
var svgDrawer = (function() {
|
|
||||||
|
|
||||||
var Drawing = function (el, htOption) {
|
|
||||||
this._el = el;
|
|
||||||
this._htOption = htOption;
|
|
||||||
};
|
|
||||||
|
|
||||||
Drawing.prototype.draw = function (oQRCode) {
|
|
||||||
var _htOption = this._htOption;
|
|
||||||
var _el = this._el;
|
|
||||||
var nCount = oQRCode.getModuleCount();
|
|
||||||
var nWidth = Math.floor(_htOption.width / nCount);
|
|
||||||
var nHeight = Math.floor(_htOption.height / nCount);
|
|
||||||
|
|
||||||
this.clear();
|
|
||||||
|
|
||||||
function makeSVG(tag, attrs) {
|
|
||||||
var el = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
|
||||||
for (var k in attrs)
|
|
||||||
if (attrs.hasOwnProperty(k)) el.setAttribute(k, attrs[k]);
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
var svg = makeSVG("svg" , {'viewBox': '0 0 ' + String(nCount) + " " + String(nCount), 'width': '100%', 'height': '100%', 'fill': _htOption.colorLight});
|
|
||||||
svg.setAttributeNS("http://www.w3.org/2000/xmlns/", "xmlns:xlink", "http://www.w3.org/1999/xlink");
|
|
||||||
_el.appendChild(svg);
|
|
||||||
|
|
||||||
svg.appendChild(makeSVG("rect", {"fill": _htOption.colorLight, "width": "100%", "height": "100%"}));
|
|
||||||
svg.appendChild(makeSVG("rect", {"fill": _htOption.colorDark, "width": "1", "height": "1", "id": "template"}));
|
|
||||||
|
|
||||||
for (var row = 0; row < nCount; row++) {
|
|
||||||
for (var col = 0; col < nCount; col++) {
|
|
||||||
if (oQRCode.isDark(row, col)) {
|
|
||||||
var child = makeSVG("use", {"x": String(col), "y": String(row)});
|
|
||||||
child.setAttributeNS("http://www.w3.org/1999/xlink", "href", "#template")
|
|
||||||
svg.appendChild(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Drawing.prototype.clear = function () {
|
|
||||||
while (this._el.hasChildNodes())
|
|
||||||
this._el.removeChild(this._el.lastChild);
|
|
||||||
};
|
|
||||||
return Drawing;
|
|
||||||
})();
|
|
||||||
|
|
||||||
var useSVG = document.documentElement.tagName.toLowerCase() === "svg";
|
|
||||||
|
|
||||||
// Drawing in DOM by using Table tag
|
|
||||||
var Drawing = useSVG ? svgDrawer : !_isSupportCanvas() ? (function () {
|
|
||||||
var Drawing = function (el, htOption) {
|
|
||||||
this._el = el;
|
|
||||||
this._htOption = htOption;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draw the QRCode
|
|
||||||
*
|
|
||||||
* @param {QRCode} oQRCode
|
|
||||||
*/
|
|
||||||
Drawing.prototype.draw = function (oQRCode) {
|
|
||||||
var _htOption = this._htOption;
|
|
||||||
var _el = this._el;
|
|
||||||
var nCount = oQRCode.getModuleCount();
|
|
||||||
var nWidth = Math.floor(_htOption.width / nCount);
|
|
||||||
var nHeight = Math.floor(_htOption.height / nCount);
|
|
||||||
var aHTML = ['<table style="border:0;border-collapse:collapse;">'];
|
|
||||||
|
|
||||||
for (var row = 0; row < nCount; row++) {
|
|
||||||
aHTML.push('<tr>');
|
|
||||||
|
|
||||||
for (var col = 0; col < nCount; col++) {
|
|
||||||
aHTML.push('<td style="border:0;border-collapse:collapse;padding:0;margin:0;width:' + nWidth + 'px;height:' + nHeight + 'px;background-color:' + (oQRCode.isDark(row, col) ? _htOption.colorDark : _htOption.colorLight) + ';"></td>');
|
|
||||||
}
|
|
||||||
|
|
||||||
aHTML.push('</tr>');
|
|
||||||
}
|
|
||||||
|
|
||||||
aHTML.push('</table>');
|
|
||||||
_el.innerHTML = aHTML.join('');
|
|
||||||
|
|
||||||
// Fix the margin values as real size.
|
|
||||||
var elTable = _el.childNodes[0];
|
|
||||||
var nLeftMarginTable = (_htOption.width - elTable.offsetWidth) / 2;
|
|
||||||
var nTopMarginTable = (_htOption.height - elTable.offsetHeight) / 2;
|
|
||||||
|
|
||||||
if (nLeftMarginTable > 0 && nTopMarginTable > 0) {
|
|
||||||
elTable.style.margin = nTopMarginTable + "px " + nLeftMarginTable + "px";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the QRCode
|
|
||||||
*/
|
|
||||||
Drawing.prototype.clear = function () {
|
|
||||||
this._el.innerHTML = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
return Drawing;
|
|
||||||
})() : (function () { // Drawing in Canvas
|
|
||||||
function _onMakeImage() {
|
|
||||||
this._elImage.src = this._elCanvas.toDataURL("image/png");
|
|
||||||
this._elImage.style.display = "block";
|
|
||||||
this._elCanvas.style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Android 2.1 bug workaround
|
|
||||||
// http://code.google.com/p/android/issues/detail?id=5141
|
|
||||||
if (this._android && this._android <= 2.1) {
|
|
||||||
var factor = 1 / window.devicePixelRatio;
|
|
||||||
var drawImage = CanvasRenderingContext2D.prototype.drawImage;
|
|
||||||
CanvasRenderingContext2D.prototype.drawImage = function (image, sx, sy, sw, sh, dx, dy, dw, dh) {
|
|
||||||
if (("nodeName" in image) && /img/i.test(image.nodeName)) {
|
|
||||||
for (var i = arguments.length - 1; i >= 1; i--) {
|
|
||||||
arguments[i] = arguments[i] * factor;
|
|
||||||
}
|
|
||||||
} else if (typeof dw == "undefined") {
|
|
||||||
arguments[1] *= factor;
|
|
||||||
arguments[2] *= factor;
|
|
||||||
arguments[3] *= factor;
|
|
||||||
arguments[4] *= factor;
|
|
||||||
}
|
|
||||||
|
|
||||||
drawImage.apply(this, arguments);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether the user's browser supports Data URI or not
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
* @param {Function} fSuccess Occurs if it supports Data URI
|
|
||||||
* @param {Function} fFail Occurs if it doesn't support Data URI
|
|
||||||
*/
|
|
||||||
function _safeSetDataURI(fSuccess, fFail) {
|
|
||||||
var self = this;
|
|
||||||
self._fFail = fFail;
|
|
||||||
self._fSuccess = fSuccess;
|
|
||||||
|
|
||||||
// Check it just once
|
|
||||||
if (self._bSupportDataURI === null) {
|
|
||||||
var el = document.createElement("img");
|
|
||||||
var fOnError = function() {
|
|
||||||
self._bSupportDataURI = false;
|
|
||||||
|
|
||||||
if (self._fFail) {
|
|
||||||
self._fFail.call(self);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
var fOnSuccess = function() {
|
|
||||||
self._bSupportDataURI = true;
|
|
||||||
|
|
||||||
if (self._fSuccess) {
|
|
||||||
self._fSuccess.call(self);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
el.onabort = fOnError;
|
|
||||||
el.onerror = fOnError;
|
|
||||||
el.onload = fOnSuccess;
|
|
||||||
el.src = "data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; // the Image contains 1px data.
|
|
||||||
return;
|
|
||||||
} else if (self._bSupportDataURI === true && self._fSuccess) {
|
|
||||||
self._fSuccess.call(self);
|
|
||||||
} else if (self._bSupportDataURI === false && self._fFail) {
|
|
||||||
self._fFail.call(self);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drawing QRCode by using canvas
|
|
||||||
*
|
|
||||||
* @constructor
|
|
||||||
* @param {HTMLElement} el
|
|
||||||
* @param {Object} htOption QRCode Options
|
|
||||||
*/
|
|
||||||
var Drawing = function (el, htOption) {
|
|
||||||
this._bIsPainted = false;
|
|
||||||
this._android = _getAndroid();
|
|
||||||
|
|
||||||
this._htOption = htOption;
|
|
||||||
this._elCanvas = document.createElement("canvas");
|
|
||||||
this._elCanvas.width = htOption.width;
|
|
||||||
this._elCanvas.height = htOption.height;
|
|
||||||
el.appendChild(this._elCanvas);
|
|
||||||
this._el = el;
|
|
||||||
this._oContext = this._elCanvas.getContext("2d");
|
|
||||||
this._bIsPainted = false;
|
|
||||||
this._elImage = document.createElement("img");
|
|
||||||
this._elImage.alt = "Scan me!";
|
|
||||||
this._elImage.style.display = "none";
|
|
||||||
this._el.appendChild(this._elImage);
|
|
||||||
this._bSupportDataURI = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Draw the QRCode
|
|
||||||
*
|
|
||||||
* @param {QRCode} oQRCode
|
|
||||||
*/
|
|
||||||
Drawing.prototype.draw = function (oQRCode) {
|
|
||||||
var _elImage = this._elImage;
|
|
||||||
var _oContext = this._oContext;
|
|
||||||
var _htOption = this._htOption;
|
|
||||||
|
|
||||||
var nCount = oQRCode.getModuleCount();
|
|
||||||
var nWidth = _htOption.width / nCount;
|
|
||||||
var nHeight = _htOption.height / nCount;
|
|
||||||
var nRoundedWidth = Math.round(nWidth);
|
|
||||||
var nRoundedHeight = Math.round(nHeight);
|
|
||||||
|
|
||||||
_elImage.style.display = "none";
|
|
||||||
this.clear();
|
|
||||||
|
|
||||||
for (var row = 0; row < nCount; row++) {
|
|
||||||
for (var col = 0; col < nCount; col++) {
|
|
||||||
var bIsDark = oQRCode.isDark(row, col);
|
|
||||||
var nLeft = col * nWidth;
|
|
||||||
var nTop = row * nHeight;
|
|
||||||
_oContext.strokeStyle = bIsDark ? _htOption.colorDark : _htOption.colorLight;
|
|
||||||
_oContext.lineWidth = 1;
|
|
||||||
_oContext.fillStyle = bIsDark ? _htOption.colorDark : _htOption.colorLight;
|
|
||||||
_oContext.fillRect(nLeft, nTop, nWidth, nHeight);
|
|
||||||
|
|
||||||
// 안티 앨리어싱 방지 처리
|
|
||||||
_oContext.strokeRect(
|
|
||||||
Math.floor(nLeft) + 0.5,
|
|
||||||
Math.floor(nTop) + 0.5,
|
|
||||||
nRoundedWidth,
|
|
||||||
nRoundedHeight
|
|
||||||
);
|
|
||||||
|
|
||||||
_oContext.strokeRect(
|
|
||||||
Math.ceil(nLeft) - 0.5,
|
|
||||||
Math.ceil(nTop) - 0.5,
|
|
||||||
nRoundedWidth,
|
|
||||||
nRoundedHeight
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this._bIsPainted = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make the image from Canvas if the browser supports Data URI.
|
|
||||||
*/
|
|
||||||
Drawing.prototype.makeImage = function () {
|
|
||||||
if (this._bIsPainted) {
|
|
||||||
_safeSetDataURI.call(this, _onMakeImage);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return whether the QRCode is painted or not
|
|
||||||
*
|
|
||||||
* @return {Boolean}
|
|
||||||
*/
|
|
||||||
Drawing.prototype.isPainted = function () {
|
|
||||||
return this._bIsPainted;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the QRCode
|
|
||||||
*/
|
|
||||||
Drawing.prototype.clear = function () {
|
|
||||||
this._oContext.clearRect(0, 0, this._elCanvas.width, this._elCanvas.height);
|
|
||||||
this._bIsPainted = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @private
|
|
||||||
* @param {Number} nNumber
|
|
||||||
*/
|
|
||||||
Drawing.prototype.round = function (nNumber) {
|
|
||||||
if (!nNumber) {
|
|
||||||
return nNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Math.floor(nNumber * 1000) / 1000;
|
|
||||||
};
|
|
||||||
|
|
||||||
return Drawing;
|
|
||||||
})();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the type by string length
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
* @param {String} sText
|
|
||||||
* @param {Number} nCorrectLevel
|
|
||||||
* @return {Number} type
|
|
||||||
*/
|
|
||||||
function _getTypeNumber(sText, nCorrectLevel) {
|
|
||||||
var nType = 1;
|
|
||||||
var length = _getUTF8Length(sText);
|
|
||||||
|
|
||||||
for (var i = 0, len = QRCodeLimitLength.length; i <= len; i++) {
|
|
||||||
var nLimit = 0;
|
|
||||||
|
|
||||||
switch (nCorrectLevel) {
|
|
||||||
case QRErrorCorrectLevel.L :
|
|
||||||
nLimit = QRCodeLimitLength[i][0];
|
|
||||||
break;
|
|
||||||
case QRErrorCorrectLevel.M :
|
|
||||||
nLimit = QRCodeLimitLength[i][1];
|
|
||||||
break;
|
|
||||||
case QRErrorCorrectLevel.Q :
|
|
||||||
nLimit = QRCodeLimitLength[i][2];
|
|
||||||
break;
|
|
||||||
case QRErrorCorrectLevel.H :
|
|
||||||
nLimit = QRCodeLimitLength[i][3];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (length <= nLimit) {
|
|
||||||
break;
|
|
||||||
} else {
|
|
||||||
nType++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nType > QRCodeLimitLength.length) {
|
|
||||||
throw new Error("Too long data");
|
|
||||||
}
|
|
||||||
|
|
||||||
return nType;
|
|
||||||
}
|
|
||||||
|
|
||||||
function _getUTF8Length(sText) {
|
|
||||||
var replacedText = encodeURI(sText).toString().replace(/\%[0-9a-fA-F]{2}/g, 'a');
|
|
||||||
return replacedText.length + (replacedText.length != sText ? 3 : 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @class QRCode
|
|
||||||
* @constructor
|
|
||||||
* @example
|
|
||||||
* new QRCode(document.getElementById("test"), "http://jindo.dev.naver.com/collie");
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* var oQRCode = new QRCode("test", {
|
|
||||||
* text : "http://naver.com",
|
|
||||||
* width : 128,
|
|
||||||
* height : 128
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* oQRCode.clear(); // Clear the QRCode.
|
|
||||||
* oQRCode.makeCode("http://map.naver.com"); // Re-create the QRCode.
|
|
||||||
*
|
|
||||||
* @param {HTMLElement|String} el target element or 'id' attribute of element.
|
|
||||||
* @param {Object|String} vOption
|
|
||||||
* @param {String} vOption.text QRCode link data
|
|
||||||
* @param {Number} [vOption.width=256]
|
|
||||||
* @param {Number} [vOption.height=256]
|
|
||||||
* @param {String} [vOption.colorDark="#000000"]
|
|
||||||
* @param {String} [vOption.colorLight="#ffffff"]
|
|
||||||
* @param {QRCode.CorrectLevel} [vOption.correctLevel=QRCode.CorrectLevel.H] [L|M|Q|H]
|
|
||||||
*/
|
|
||||||
QRCode = function (el, vOption) {
|
|
||||||
this._htOption = {
|
|
||||||
width : 256,
|
|
||||||
height : 256,
|
|
||||||
typeNumber : 4,
|
|
||||||
colorDark : "#000000",
|
|
||||||
colorLight : "#ffffff",
|
|
||||||
correctLevel : QRErrorCorrectLevel.H
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof vOption === 'string') {
|
|
||||||
vOption = {
|
|
||||||
text : vOption
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overwrites options
|
|
||||||
if (vOption) {
|
|
||||||
for (var i in vOption) {
|
|
||||||
this._htOption[i] = vOption[i];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof el == "string") {
|
|
||||||
el = document.getElementById(el);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._htOption.useSVG) {
|
|
||||||
Drawing = svgDrawer;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._android = _getAndroid();
|
|
||||||
this._el = el;
|
|
||||||
this._oQRCode = null;
|
|
||||||
this._oDrawing = new Drawing(this._el, this._htOption);
|
|
||||||
|
|
||||||
if (this._htOption.text) {
|
|
||||||
this.makeCode(this._htOption.text);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make the QRCode
|
|
||||||
*
|
|
||||||
* @param {String} sText link data
|
|
||||||
*/
|
|
||||||
QRCode.prototype.makeCode = function (sText) {
|
|
||||||
this._oQRCode = new QRCodeModel(_getTypeNumber(sText, this._htOption.correctLevel), this._htOption.correctLevel);
|
|
||||||
this._oQRCode.addData(sText);
|
|
||||||
this._oQRCode.make();
|
|
||||||
this._el.title = sText;
|
|
||||||
this._oDrawing.draw(this._oQRCode);
|
|
||||||
this.makeImage();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make the Image from Canvas element
|
|
||||||
* - It occurs automatically
|
|
||||||
* - Android below 3 doesn't support Data-URI spec.
|
|
||||||
*
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
QRCode.prototype.makeImage = function () {
|
|
||||||
if (typeof this._oDrawing.makeImage == "function" && (!this._android || this._android >= 3)) {
|
|
||||||
this._oDrawing.makeImage();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear the QRCode
|
|
||||||
*/
|
|
||||||
QRCode.prototype.clear = function () {
|
|
||||||
this._oDrawing.clear();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @name QRCode.CorrectLevel
|
|
||||||
*/
|
|
||||||
QRCode.CorrectLevel = QRErrorCorrectLevel;
|
|
||||||
})();
|
|
1
static/qrcodejs/qrcode.min.js
vendored
1
static/qrcodejs/qrcode.min.js
vendored
File diff suppressed because one or more lines are too long
@ -1,29 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Register - Passkey Authentication</title>
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
|
||||||
<script src="/static/simplewebauthn-browser.min.js"></script>
|
|
||||||
<script src="/static/awaitable-websocket.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<!-- Register View -->
|
|
||||||
<div id="registerView" class="view active">
|
|
||||||
<h1>🔐 Create Account</h1>
|
|
||||||
<div id="registerStatus"></div>
|
|
||||||
<form id="registrationForm">
|
|
||||||
<input type="text" name="username" placeholder="Enter username" required>
|
|
||||||
<button type="submit" class="btn-primary">Register Passkey</button>
|
|
||||||
</form>
|
|
||||||
<p class="toggle-link" onclick="window.location.href='/auth/login'">
|
|
||||||
Already have an account? Login here
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
|
||||||
<script src="/static/util.js"></script>
|
|
||||||
<script src="/static/register.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,42 +0,0 @@
|
|||||||
// Register page specific functionality
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Initialize the app
|
|
||||||
initializeApp()
|
|
||||||
|
|
||||||
// Registration form handler
|
|
||||||
const regForm = document.getElementById('registrationForm')
|
|
||||||
if (regForm) {
|
|
||||||
const regSubmitBtn = regForm.querySelector('button[type="submit"]')
|
|
||||||
|
|
||||||
regForm.addEventListener('submit', ev => {
|
|
||||||
ev.preventDefault()
|
|
||||||
clearStatus('registerStatus')
|
|
||||||
const user_name = (new FormData(regForm)).get('username')
|
|
||||||
regSubmitBtn.disabled = true
|
|
||||||
|
|
||||||
const ahandler = async () => {
|
|
||||||
try {
|
|
||||||
showStatus('registerStatus', 'Starting registration...', 'info')
|
|
||||||
await register(user_name)
|
|
||||||
showStatus('registerStatus', `Registration successful for ${user_name}!`, 'success')
|
|
||||||
|
|
||||||
// Auto-login after successful registration
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = '/'
|
|
||||||
}, 1500)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Registration error:', err)
|
|
||||||
if (err.name === "NotAllowedError") {
|
|
||||||
showStatus('registerStatus', `Registration cancelled`, 'error')
|
|
||||||
} else {
|
|
||||||
showStatus('registerStatus', `Registration failed: ${err.message}`, 'error')
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
regSubmitBtn.disabled = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ahandler()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
|
@ -1,185 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>Add Device - Passkey Authentication</title>
|
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
|
||||||
<script src="/static/simplewebauthn-browser.min.js"></script>
|
|
||||||
<script src="/static/qrcodejs/qrcode.min.js"></script>
|
|
||||||
<script src="/static/awaitable-websocket.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<!-- Request Reset View -->
|
|
||||||
<div id="requestView" class="view">
|
|
||||||
<h1>🔓 Add Device</h1>
|
|
||||||
<p>This page is for adding a new device to an existing account. You need a device addition link to proceed.</p>
|
|
||||||
<div id="requestStatus" class="status info" style="display: block;">
|
|
||||||
<strong>How to get a device addition link:</strong><br>
|
|
||||||
1. Log into your account on a device you already have<br>
|
|
||||||
2. Click "Generate Device Link" in your dashboard<br>
|
|
||||||
3. Copy the link or scan the QR code to add this device
|
|
||||||
</div>
|
|
||||||
<p class="toggle-link" onclick="window.location.href='/'">
|
|
||||||
Back to Login
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add Passkey View -->
|
|
||||||
<div id="addPasskeyView" class="view">
|
|
||||||
<h1>🔑 Add New Passkey</h1>
|
|
||||||
<div id="userInfo" class="token-info">
|
|
||||||
<p><strong>Account:</strong> <span id="userName"></span></p>
|
|
||||||
<p><small>You are about to add a new passkey to this account.</small></p>
|
|
||||||
</div>
|
|
||||||
<div id="addPasskeyStatus" class="status"></div>
|
|
||||||
<button id="addPasskeyBtn" class="btn-primary">Add New Passkey</button>
|
|
||||||
<p class="toggle-link" onclick="window.location.href='/'">
|
|
||||||
Back to Login
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Success Complete View -->
|
|
||||||
<div id="completeView" class="view">
|
|
||||||
<h1>🎉 Passkey Added Successfully!</h1>
|
|
||||||
<p>Your new passkey has been added to your account. You can now use it to log in.</p>
|
|
||||||
<button onclick="window.location.href='/'" class="btn-primary">Go to Login</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const { startRegistration } = SimpleWebAuthnBrowser;
|
|
||||||
|
|
||||||
// Global state
|
|
||||||
let currentToken = null;
|
|
||||||
let currentUser = null;
|
|
||||||
|
|
||||||
// View management
|
|
||||||
function showView(viewId) {
|
|
||||||
document.querySelectorAll('.view').forEach(view => {
|
|
||||||
view.classList.remove('active');
|
|
||||||
});
|
|
||||||
document.getElementById(viewId).classList.add('active');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showAddPasskeyView() {
|
|
||||||
showView('addPasskeyView');
|
|
||||||
clearStatus('addPasskeyStatus');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showCompleteView() {
|
|
||||||
showView('completeView');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status management
|
|
||||||
function showStatus(elementId, message, type = 'info') {
|
|
||||||
const statusEl = document.getElementById(elementId);
|
|
||||||
statusEl.textContent = message;
|
|
||||||
statusEl.className = `status ${type}`;
|
|
||||||
statusEl.style.display = 'block';
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearStatus(elementId) {
|
|
||||||
const statusEl = document.getElementById(elementId);
|
|
||||||
statusEl.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate reset token and show add passkey view
|
|
||||||
async function validateTokenAndShowAddView(token) {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/validate-device-token', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ token })
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentToken = token;
|
|
||||||
currentUser = result;
|
|
||||||
document.getElementById('userName').textContent = result.user_name;
|
|
||||||
showAddPasskeyView();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
showStatus('addPasskeyStatus', `Error: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new passkey via reset token
|
|
||||||
async function addPasskeyWithToken(token) {
|
|
||||||
try {
|
|
||||||
const ws = await aWebSocket('/ws/add_device_credential');
|
|
||||||
|
|
||||||
// Send token to server
|
|
||||||
ws.send(JSON.stringify({ token }));
|
|
||||||
|
|
||||||
// Get registration options
|
|
||||||
const optionsJSON = JSON.parse(await ws.recv());
|
|
||||||
if (optionsJSON.error) throw new Error(optionsJSON.error);
|
|
||||||
|
|
||||||
showStatus('addPasskeyStatus', 'Save new passkey to your authenticator...', 'info');
|
|
||||||
|
|
||||||
const registrationResponse = await startRegistration({ optionsJSON });
|
|
||||||
ws.send(JSON.stringify(registrationResponse));
|
|
||||||
|
|
||||||
const result = JSON.parse(await ws.recv());
|
|
||||||
if (result.error) throw new Error(`Server: ${result.error}`);
|
|
||||||
|
|
||||||
ws.close();
|
|
||||||
|
|
||||||
showCompleteView();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
showStatus('addPasskeyStatus', `Failed to add passkey: ${error.message}`, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check URL path for token on page load
|
|
||||||
function checkUrlParams() {
|
|
||||||
const path = window.location.pathname;
|
|
||||||
const pathParts = path.split('/');
|
|
||||||
|
|
||||||
// Check if URL is in format /reset/token
|
|
||||||
if (pathParts.length >= 3 && pathParts[1] === 'reset') {
|
|
||||||
const token = pathParts[2];
|
|
||||||
if (token) {
|
|
||||||
validateTokenAndShowAddView(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form event handlers
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
// Check for token in URL
|
|
||||||
checkUrlParams();
|
|
||||||
|
|
||||||
// Add passkey button
|
|
||||||
const addPasskeyBtn = document.getElementById('addPasskeyBtn');
|
|
||||||
|
|
||||||
addPasskeyBtn.addEventListener('click', async () => {
|
|
||||||
if (!currentToken) {
|
|
||||||
showStatus('addPasskeyStatus', 'No valid device addition token found', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
addPasskeyBtn.disabled = true;
|
|
||||||
clearStatus('addPasskeyStatus');
|
|
||||||
|
|
||||||
try {
|
|
||||||
showStatus('addPasskeyStatus', 'Starting passkey registration...', 'info');
|
|
||||||
await addPasskeyWithToken(currentToken);
|
|
||||||
} catch (err) {
|
|
||||||
showStatus('addPasskeyStatus', `Registration failed: ${err.message}`, 'error');
|
|
||||||
} finally {
|
|
||||||
addPasskeyBtn.disabled = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
2
static/simplewebauthn-browser.min.js
vendored
2
static/simplewebauthn-browser.min.js
vendored
File diff suppressed because one or more lines are too long
328
static/style.css
328
static/style.css
@ -1,328 +0,0 @@
|
|||||||
/* Passkey Authentication - Main Styles */
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
background: white;
|
|
||||||
padding: 40px;
|
|
||||||
border-radius: 15px;
|
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view.active {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
font-weight: 300;
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
color: #555;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
font-weight: 400;
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"] {
|
|
||||||
width: 100%;
|
|
||||||
padding: 15px;
|
|
||||||
border: 2px solid #e1e5e9;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 16px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
transition: border-color 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"]:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 15px;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary:hover:not(:disabled) {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
background: transparent;
|
|
||||||
color: #667eea;
|
|
||||||
border: 2px solid #667eea;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary:hover:not(:disabled) {
|
|
||||||
background: #667eea;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger {
|
|
||||||
background: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-danger:hover:not(:disabled) {
|
|
||||||
background: #c82333;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:disabled {
|
|
||||||
background: #ccc !important;
|
|
||||||
cursor: not-allowed !important;
|
|
||||||
transform: none !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status {
|
|
||||||
padding: 10px;
|
|
||||||
margin: 15px 0;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.success {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
border: 1px solid #c3e6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.error {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
border: 1px solid #f5c6cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status.info {
|
|
||||||
background: #d1ecf1;
|
|
||||||
color: #0c5460;
|
|
||||||
border: 1px solid #bee5eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-list {
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-item {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 15px;
|
|
||||||
margin: 10px 0;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-item.current-session {
|
|
||||||
border: 2px solid #007bff;
|
|
||||||
background: #f8f9ff;
|
|
||||||
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-item.current-session .credential-info h4 {
|
|
||||||
color: #0056b3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-header {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 32px 1fr auto auto;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-icon {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-icon {
|
|
||||||
border-radius: 4px;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.auth-emoji {
|
|
||||||
font-size: 24px;
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-info {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-info h4 {
|
|
||||||
margin: 0;
|
|
||||||
color: #333;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-dates {
|
|
||||||
text-align: right;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-left: 20px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: auto auto;
|
|
||||||
gap: 5px 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-label {
|
|
||||||
color: #666;
|
|
||||||
font-weight: normal;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-value {
|
|
||||||
color: #333;
|
|
||||||
font-size: 12px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info {
|
|
||||||
background: #e7f3ff;
|
|
||||||
border: 1px solid #bee5eb;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 15px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info h3 {
|
|
||||||
margin: 0 0 10px 0;
|
|
||||||
color: #0c5460;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-info p {
|
|
||||||
margin: 5px 0;
|
|
||||||
color: #0c5460;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-link {
|
|
||||||
color: #667eea;
|
|
||||||
text-decoration: underline;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-link:hover {
|
|
||||||
color: #764ba2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credential-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete-credential {
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 16px;
|
|
||||||
color: #dc3545;
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete-credential:hover:not(:disabled) {
|
|
||||||
background-color: #f8d7da;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-delete-credential:disabled {
|
|
||||||
opacity: 0.3;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token-info {
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin: 15px 0;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token-info strong {
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token-info code {
|
|
||||||
background: #e9ecef;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.qr-code {
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 10px;
|
|
||||||
background: white;
|
|
||||||
margin: 10px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-container {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 15px;
|
|
||||||
margin: 10px 0;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-container .link-text {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
color: #495057;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
104
static/util.js
104
static/util.js
@ -1,104 +0,0 @@
|
|||||||
// Shared utility functions for all views
|
|
||||||
|
|
||||||
// Initialize the app based on current page
|
|
||||||
function initializeApp() {
|
|
||||||
checkExistingSession()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show status message
|
|
||||||
function showStatus(elementId, message, type = 'info') {
|
|
||||||
const statusEl = document.getElementById(elementId)
|
|
||||||
if (statusEl) {
|
|
||||||
statusEl.innerHTML = `<div class="status ${type}">${message}</div>`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear status message
|
|
||||||
function clearStatus(elementId) {
|
|
||||||
const statusEl = document.getElementById(elementId)
|
|
||||||
if (statusEl) {
|
|
||||||
statusEl.innerHTML = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is already logged in on page load
|
|
||||||
async function checkExistingSession() {
|
|
||||||
const isLoggedIn = await validateStoredToken()
|
|
||||||
const path = window.location.pathname
|
|
||||||
|
|
||||||
// Protected routes that require authentication
|
|
||||||
const protectedRoutes = ['/auth/profile']
|
|
||||||
|
|
||||||
if (isLoggedIn) {
|
|
||||||
// User is logged in
|
|
||||||
if (path === '/auth/login' || path === '/auth/register' || path === '/') {
|
|
||||||
// Redirect to profile if accessing login/register pages while logged in
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
} else if (path === '/auth/add-device') {
|
|
||||||
// Redirect old add-device route to profile
|
|
||||||
window.location.href = '/auth/profile'
|
|
||||||
} else if (protectedRoutes.includes(path)) {
|
|
||||||
// Stay on current protected page and load user data
|
|
||||||
if (path === '/auth/profile') {
|
|
||||||
try {
|
|
||||||
await loadUserInfo()
|
|
||||||
updateUserInfo()
|
|
||||||
await loadCredentials()
|
|
||||||
} catch (error) {
|
|
||||||
showStatus('profileStatus', `Failed to load user info: ${error.message}`, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// User is not logged in
|
|
||||||
if (protectedRoutes.includes(path) || path === '/auth/add-device') {
|
|
||||||
// Redirect to login if accessing protected pages without authentication
|
|
||||||
window.location.href = '/auth/login'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate stored token
|
|
||||||
async function validateStoredToken() {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/validate-token', {
|
|
||||||
method: 'GET',
|
|
||||||
credentials: 'include'
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
return result.status === 'success'
|
|
||||||
} catch (error) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy device link to clipboard
|
|
||||||
async function copyDeviceLink() {
|
|
||||||
try {
|
|
||||||
if (window.currentDeviceLink) {
|
|
||||||
await navigator.clipboard.writeText(window.currentDeviceLink)
|
|
||||||
|
|
||||||
const copyButton = document.querySelector('.copy-button')
|
|
||||||
if (copyButton) {
|
|
||||||
const originalText = copyButton.textContent
|
|
||||||
copyButton.textContent = 'Copied!'
|
|
||||||
copyButton.style.background = '#28a745'
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
copyButton.textContent = originalText
|
|
||||||
copyButton.style.background = '#28a745'
|
|
||||||
}, 2000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to copy link:', error)
|
|
||||||
const linkText = document.getElementById('deviceLinkText')
|
|
||||||
if (linkText) {
|
|
||||||
const range = document.createRange()
|
|
||||||
range.selectNode(linkText)
|
|
||||||
window.getSelection().removeAllRanges()
|
|
||||||
window.getSelection().addRange(range)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
x
Reference in New Issue
Block a user