Mostly working, saving.
This commit is contained in:
parent
1c9044054a
commit
52520c18b1
@ -36,6 +36,7 @@ async def get_user_info(request: Request) -> dict:
|
|||||||
"user_name": user.user_name,
|
"user_name": user.user_name,
|
||||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||||
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
|
"last_seen": user.last_seen.isoformat() if user.last_seen else None,
|
||||||
|
"visits": user.visits,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -5,9 +5,10 @@ This module provides an async database layer using SQLAlchemy async mode
|
|||||||
for managing users and credentials in a WebAuthn authentication system.
|
for managing users and credentials in a WebAuthn authentication system.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
@ -41,6 +42,7 @@ class UserModel(Base):
|
|||||||
user_name: Mapped[str] = mapped_column(String, nullable=False)
|
user_name: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
||||||
last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
last_seen: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
visits: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
# Relationship to credentials
|
# Relationship to credentials
|
||||||
credentials: Mapped[list["CredentialModel"]] = relationship(
|
credentials: Mapped[list["CredentialModel"]] = relationship(
|
||||||
@ -66,12 +68,33 @@ class CredentialModel(Base):
|
|||||||
user: Mapped["UserModel"] = relationship("UserModel", back_populates="credentials")
|
user: Mapped["UserModel"] = relationship("UserModel", back_populates="credentials")
|
||||||
|
|
||||||
|
|
||||||
|
class ResetTokenModel(Base):
|
||||||
|
__tablename__ = "reset_tokens"
|
||||||
|
|
||||||
|
token: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
user_id: Mapped[bytes] = mapped_column(
|
||||||
|
LargeBinary(16), ForeignKey("users.user_id", ondelete="CASCADE")
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.now)
|
||||||
|
|
||||||
|
# Relationship to user
|
||||||
|
user: Mapped["UserModel"] = relationship("UserModel")
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class User:
|
class User:
|
||||||
user_id: UUID
|
user_id: UUID
|
||||||
user_name: str
|
user_name: str
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
last_seen: datetime | None = None
|
last_seen: datetime | None = None
|
||||||
|
visits: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ResetToken:
|
||||||
|
token: str
|
||||||
|
user_id: UUID
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
# Global engine and session factory
|
# Global engine and session factory
|
||||||
@ -108,6 +131,7 @@ class DB:
|
|||||||
user_name=user_model.user_name,
|
user_name=user_model.user_name,
|
||||||
created_at=user_model.created_at,
|
created_at=user_model.created_at,
|
||||||
last_seen=user_model.last_seen,
|
last_seen=user_model.last_seen,
|
||||||
|
visits=user_model.visits,
|
||||||
)
|
)
|
||||||
raise ValueError("User not found")
|
raise ValueError("User not found")
|
||||||
|
|
||||||
@ -118,6 +142,7 @@ class DB:
|
|||||||
user_name=user.user_name,
|
user_name=user.user_name,
|
||||||
created_at=user.created_at or datetime.now(),
|
created_at=user.created_at or datetime.now(),
|
||||||
last_seen=user.last_seen,
|
last_seen=user.last_seen,
|
||||||
|
visits=user.visits,
|
||||||
)
|
)
|
||||||
self.session.add(user_model)
|
self.session.add(user_model)
|
||||||
await self.session.flush()
|
await self.session.flush()
|
||||||
@ -186,11 +211,27 @@ class DB:
|
|||||||
# Update credential
|
# Update credential
|
||||||
await self.update_credential(credential)
|
await self.update_credential(credential)
|
||||||
|
|
||||||
# Update user's last_seen
|
# Update user's last_seen and increment visits
|
||||||
stmt = (
|
stmt = (
|
||||||
update(UserModel)
|
update(UserModel)
|
||||||
.where(UserModel.user_id == user_id.bytes)
|
.where(UserModel.user_id == user_id.bytes)
|
||||||
.values(last_seen=credential.last_used)
|
.values(last_seen=credential.last_used, visits=UserModel.visits + 1)
|
||||||
|
)
|
||||||
|
await self.session.execute(stmt)
|
||||||
|
|
||||||
|
async def create_new_session(
|
||||||
|
self, user_id: UUID, credential: StoredCredential
|
||||||
|
) -> None:
|
||||||
|
"""Create a new session for a user by incrementing visits and updating last_seen."""
|
||||||
|
async with self.session.begin():
|
||||||
|
# Update credential
|
||||||
|
await self.update_credential(credential)
|
||||||
|
|
||||||
|
# Update user's last_seen and increment visits
|
||||||
|
stmt = (
|
||||||
|
update(UserModel)
|
||||||
|
.where(UserModel.user_id == user_id.bytes)
|
||||||
|
.values(last_seen=credential.last_used, visits=UserModel.visits + 1)
|
||||||
)
|
)
|
||||||
await self.session.execute(stmt)
|
await self.session.execute(stmt)
|
||||||
|
|
||||||
@ -202,6 +243,61 @@ class DB:
|
|||||||
await self.session.execute(stmt)
|
await self.session.execute(stmt)
|
||||||
await self.session.commit()
|
await self.session.commit()
|
||||||
|
|
||||||
|
async def create_reset_token(self, user_id: UUID, token: str | None = None) -> str:
|
||||||
|
"""Create a new reset token for a user."""
|
||||||
|
if token is None:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
reset_token_model = ResetTokenModel(
|
||||||
|
token=token,
|
||||||
|
user_id=user_id.bytes,
|
||||||
|
created_at=datetime.now(),
|
||||||
|
)
|
||||||
|
self.session.add(reset_token_model)
|
||||||
|
await self.session.flush()
|
||||||
|
return token
|
||||||
|
|
||||||
|
async def get_reset_token(self, token: str) -> ResetToken | None:
|
||||||
|
"""Get reset token by token string."""
|
||||||
|
stmt = select(ResetTokenModel).where(ResetTokenModel.token == token)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
token_model = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if token_model:
|
||||||
|
return ResetToken(
|
||||||
|
token=token_model.token,
|
||||||
|
user_id=UUID(bytes=token_model.user_id),
|
||||||
|
created_at=token_model.created_at,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def delete_reset_token(self, token: str) -> None:
|
||||||
|
"""Delete a reset token (used after successful credential addition)."""
|
||||||
|
stmt = delete(ResetTokenModel).where(ResetTokenModel.token == token)
|
||||||
|
await self.session.execute(stmt)
|
||||||
|
|
||||||
|
async def cleanup_expired_tokens(self) -> None:
|
||||||
|
"""Remove expired reset tokens (older than 24 hours)."""
|
||||||
|
expiry_time = datetime.now() - timedelta(hours=24)
|
||||||
|
stmt = delete(ResetTokenModel).where(ResetTokenModel.created_at < expiry_time)
|
||||||
|
await self.session.execute(stmt)
|
||||||
|
|
||||||
|
async def get_user_by_username(self, user_name: str) -> User | None:
|
||||||
|
"""Get user by username."""
|
||||||
|
stmt = select(UserModel).where(UserModel.user_name == user_name)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
user_model = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if user_model:
|
||||||
|
return User(
|
||||||
|
user_id=UUID(bytes=user_model.user_id),
|
||||||
|
user_name=user_model.user_name,
|
||||||
|
created_at=user_model.created_at,
|
||||||
|
last_seen=user_model.last_seen,
|
||||||
|
visits=user_model.visits,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# Standalone functions that handle database connections internally
|
# Standalone functions that handle database connections internally
|
||||||
async def init_database() -> None:
|
async def init_database() -> None:
|
||||||
@ -214,6 +310,8 @@ async def create_user_and_credential(user: User, credential: StoredCredential) -
|
|||||||
"""Create a new user and their first credential in a single transaction."""
|
"""Create a new user and their first credential in a single transaction."""
|
||||||
async with connect() as db:
|
async with connect() as db:
|
||||||
await db.session.begin()
|
await db.session.begin()
|
||||||
|
# Set visits to 1 for the new user since they're creating their first session
|
||||||
|
user.visits = 1
|
||||||
await db.create_user(user)
|
await db.create_user(user)
|
||||||
await db.create_credential(credential)
|
await db.create_credential(credential)
|
||||||
|
|
||||||
@ -252,3 +350,39 @@ async def delete_user_credential(credential_id: bytes) -> None:
|
|||||||
"""Delete a credential by its ID."""
|
"""Delete a credential by its ID."""
|
||||||
async with connect() as db:
|
async with connect() as db:
|
||||||
await db.delete_credential(credential_id)
|
await db.delete_credential(credential_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_new_session(user_id: UUID, credential: StoredCredential) -> None:
|
||||||
|
"""Create a new session for a user by incrementing visits and updating last_seen."""
|
||||||
|
async with connect() as db:
|
||||||
|
await db.create_new_session(user_id, credential)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_reset_token(user_id: UUID, token: str | None = None) -> str:
|
||||||
|
"""Create a reset token for a user."""
|
||||||
|
async with connect() as db:
|
||||||
|
return await db.create_reset_token(user_id, token)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_reset_token(token: str) -> ResetToken | None:
|
||||||
|
"""Get reset token by token string."""
|
||||||
|
async with connect() as db:
|
||||||
|
return await db.get_reset_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_reset_token(token: str) -> None:
|
||||||
|
"""Delete a reset token (used after successful credential addition)."""
|
||||||
|
async with connect() as db:
|
||||||
|
await db.delete_reset_token(token)
|
||||||
|
|
||||||
|
|
||||||
|
async def cleanup_expired_tokens() -> None:
|
||||||
|
"""Remove expired reset tokens (older than 24 hours)."""
|
||||||
|
async with connect() as db:
|
||||||
|
await db.cleanup_expired_tokens()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_username(user_name: str) -> User | None:
|
||||||
|
"""Get user by username."""
|
||||||
|
async with connect() as db:
|
||||||
|
return await db.get_user_by_username(user_name)
|
||||||
|
@ -31,6 +31,7 @@ from .api_handlers import (
|
|||||||
from .db import User
|
from .db import User
|
||||||
from .jwt_manager import create_session_token
|
from .jwt_manager import create_session_token
|
||||||
from .passkey import Passkey
|
from .passkey import Passkey
|
||||||
|
from .reset_handlers import create_device_addition_link, validate_device_addition_token
|
||||||
from .session_manager import get_user_from_cookie_string
|
from .session_manager import get_user_from_cookie_string
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).parent.parent / "static"
|
STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||||
@ -127,6 +128,64 @@ async def websocket_register_add(ws: WebSocket):
|
|||||||
await ws.send_json({"error": f"Server error: {str(e)}"})
|
await ws.send_json({"error": f"Server error: {str(e)}"})
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/add_device_credential")
|
||||||
|
async def websocket_add_device_credential(ws: WebSocket):
|
||||||
|
"""Add a new credential for an existing user via device addition token."""
|
||||||
|
await ws.accept()
|
||||||
|
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)
|
||||||
|
if not reset_token:
|
||||||
|
await ws.send_json({"error": "Invalid or expired device addition token"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if token is expired (24 hours)
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
expiry_time = reset_token.created_at + timedelta(hours=24)
|
||||||
|
if datetime.now() > expiry_time:
|
||||||
|
await ws.send_json({"error": "Device addition token has expired"})
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get user information
|
||||||
|
user = await db.get_user_by_id(reset_token.user_id)
|
||||||
|
challenge_ids = await db.get_user_credentials(reset_token.user_id)
|
||||||
|
|
||||||
|
# WebAuthn registration
|
||||||
|
credential = await register_chat(
|
||||||
|
ws, reset_token.user_id, user.user_name, challenge_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the new credential in the database
|
||||||
|
await db.create_credential_for_user(credential)
|
||||||
|
|
||||||
|
# Delete the device addition token (it's now used)
|
||||||
|
await db.delete_reset_token(token)
|
||||||
|
|
||||||
|
await ws.send_json(
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"user_id": str(reset_token.user_id),
|
||||||
|
"credential_id": credential.credential_id.hex(),
|
||||||
|
"message": "New credential added successfully via device addition token",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except ValueError as e:
|
||||||
|
await ws.send_json({"error": str(e)})
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
await ws.send_json({"error": f"Server error: {str(e)}"})
|
||||||
|
|
||||||
|
|
||||||
async def register_chat(
|
async def register_chat(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
user_id: UUID,
|
user_id: UUID,
|
||||||
@ -220,6 +279,18 @@ async def api_delete_credential(request: Request):
|
|||||||
return await delete_credential(request)
|
return await delete_credential(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/create-device-link")
|
||||||
|
async def api_create_device_link(request: Request):
|
||||||
|
"""Create a device addition link for the authenticated user."""
|
||||||
|
return await create_device_addition_link(request)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/validate-device-token")
|
||||||
|
async def api_validate_device_token(request: Request):
|
||||||
|
"""Validate a device addition token."""
|
||||||
|
return await validate_device_addition_token(request)
|
||||||
|
|
||||||
|
|
||||||
# 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")
|
||||||
|
|
||||||
@ -230,6 +301,12 @@ async def get_index():
|
|||||||
return FileResponse(STATIC_DIR / "index.html")
|
return FileResponse(STATIC_DIR / "index.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():
|
||||||
"""Entry point for the application"""
|
"""Entry point for the application"""
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
9
passkeyauth/passphrase.py
Normal file
9
passkeyauth/passphrase.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import secrets
|
||||||
|
|
||||||
|
from .wordlist import words
|
||||||
|
|
||||||
|
|
||||||
|
def generate(n=4, sep="."):
|
||||||
|
"""Generate a password of random words without repeating any word."""
|
||||||
|
wl = list(words)
|
||||||
|
return sep.join(wl.pop(secrets.randbelow(len(wl))) for i in range(n))
|
104
passkeyauth/reset_handlers.py
Normal file
104
passkeyauth/reset_handlers.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
"""
|
||||||
|
Device addition API handlers for WebAuthn authentication.
|
||||||
|
|
||||||
|
This module provides endpoints for authenticated users to:
|
||||||
|
- Generate device addition links with human-readable tokens
|
||||||
|
- Validate device addition tokens
|
||||||
|
- Add new passkeys to existing accounts via tokens
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
from .passphrase import generate
|
||||||
|
from .session_manager import get_current_user
|
||||||
|
|
||||||
|
|
||||||
|
async def create_device_addition_link(request: Request) -> dict:
|
||||||
|
"""Create a device addition link for the authenticated user."""
|
||||||
|
try:
|
||||||
|
# Require authentication
|
||||||
|
user = await get_current_user(request)
|
||||||
|
if not user:
|
||||||
|
return {"error": "Authentication required"}
|
||||||
|
|
||||||
|
# Generate a human-readable token
|
||||||
|
token = generate(n=4, sep="-") # e.g., "able-ocean-forest-dawn"
|
||||||
|
|
||||||
|
# Create reset token in database
|
||||||
|
await db.create_reset_token(user.user_id, token)
|
||||||
|
|
||||||
|
# Generate the device addition link with pretty URL
|
||||||
|
addition_link = f"http://localhost:8000/reset/{token}"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Device addition link generated successfully",
|
||||||
|
"addition_link": addition_link,
|
||||||
|
"token": token,
|
||||||
|
"expires_in_hours": 24,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Failed to create device addition link: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_device_addition_token(request: Request) -> dict:
|
||||||
|
"""Validate a device addition token and return user info."""
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
token = body.get("token")
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return {"error": "Device addition token is required"}
|
||||||
|
|
||||||
|
# Get reset token
|
||||||
|
reset_token = await db.get_reset_token(token)
|
||||||
|
if not reset_token:
|
||||||
|
return {"error": "Invalid or expired device addition token"}
|
||||||
|
|
||||||
|
# Check if token is expired (24 hours)
|
||||||
|
expiry_time = reset_token.created_at + timedelta(hours=24)
|
||||||
|
if datetime.now() > expiry_time:
|
||||||
|
return {"error": "Device addition token has expired"}
|
||||||
|
|
||||||
|
# Get user info
|
||||||
|
user = await db.get_user_by_id(reset_token.user_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"valid": True,
|
||||||
|
"user_id": str(user.user_id),
|
||||||
|
"user_name": user.user_name,
|
||||||
|
"token": token,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Failed to validate device addition token: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def use_device_addition_token(token: str) -> dict:
|
||||||
|
"""Delete a device addition token after successful use."""
|
||||||
|
try:
|
||||||
|
# Get reset token first to validate it exists and is not expired
|
||||||
|
reset_token = await db.get_reset_token(token)
|
||||||
|
if not reset_token:
|
||||||
|
return {"error": "Invalid or expired device addition token"}
|
||||||
|
|
||||||
|
# Check if token is expired (24 hours)
|
||||||
|
expiry_time = reset_token.created_at + timedelta(hours=24)
|
||||||
|
if datetime.now() > expiry_time:
|
||||||
|
return {"error": "Device addition token has expired"}
|
||||||
|
|
||||||
|
# Delete the token (it's now used)
|
||||||
|
await db.delete_reset_token(token)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"message": "Device addition token used successfully",
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"Failed to use device addition token: {str(e)}"}
|
54
passkeyauth/wordlist.py
Normal file
54
passkeyauth/wordlist.py
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# A custom list of 1024 common 3-6 letter words, with unique 3-prefixes and no prefix words, entropy 2.1b/letter 10b/word
|
||||||
|
words: list = """
|
||||||
|
able about absent abuse access acid across act adapt add adjust admit adult advice affair afraid again age agree ahead
|
||||||
|
aim air aisle alarm album alert alien all almost alone alpha also alter always amazed among amused anchor angle animal
|
||||||
|
ankle annual answer any apart appear april arch are argue army around array art ascent ash ask aspect assume asthma atom
|
||||||
|
attack audit august aunt author avoid away awful axis baby back bad bag ball bamboo bank bar base battle beach become
|
||||||
|
beef before begin behind below bench best better beyond bid bike bind bio birth bitter black bleak blind blood blue
|
||||||
|
board body boil bomb bone book border boss bottom bounce bowl box boy brain bread bring brown brush bubble buck budget
|
||||||
|
build bulk bundle burden bus but buyer buzz cable cache cage cake call came can car case catch cause cave celery cement
|
||||||
|
census cereal change check child choice chunk cigar circle city civil class clean client close club coast code coffee
|
||||||
|
coil cold come cool copy core cost cotton couch cover coyote craft cream crime cross cruel cry cube cue cult cup curve
|
||||||
|
custom cute cycle dad damage danger daring dash dawn day deal debate decide deer define degree deity delay demand denial
|
||||||
|
depth derive design detail device dial dice die differ dim dinner direct dish divert dizzy doctor dog dollar domain
|
||||||
|
donate door dose double dove draft dream drive drop drum dry duck dumb dune during dust dutch dwarf eager early east
|
||||||
|
echo eco edge edit effort egg eight either elbow elder elite else embark emerge emily employ enable end enemy engine
|
||||||
|
enjoy enlist enough enrich ensure entire envy equal era erode error erupt escape essay estate ethics evil evoke exact
|
||||||
|
excess exist exotic expect extent eye fabric face fade faith fall family fan far father fault feel female fence fetch
|
||||||
|
fever few fiber field figure file find first fish fit fix flat flesh flight float fluid fly foam focus fog foil follow
|
||||||
|
food force fossil found fox frame fresh friend frog fruit fuel fun fury future gadget gain galaxy game gap garden gas
|
||||||
|
gate gauge gaze genius ghost giant gift giggle ginger girl give glass glide globe glue goal god gold good gospel govern
|
||||||
|
gown grant great grid group grunt guard guess guide gulf gun gym habit hair half hammer hand happy hard hat have hawk
|
||||||
|
hay hazard head hedge height help hen hero hidden high hill hint hip hire hobby hockey hold home honey hood hope horse
|
||||||
|
host hotel hour hover how hub huge human hungry hurt hybrid ice icon idea idle ignore ill image immune impact income
|
||||||
|
index infant inhale inject inmate inner input inside into invest iron island issue italy item ivory jacket jaguar james
|
||||||
|
jar jazz jeans jelly jewel job joe joke joy judge juice july jump june just kansas kate keep kernel key kick kid kind
|
||||||
|
kiss kit kiwi knee knife know labor lady lag lake lamp laptop large later laugh lava law layer lazy leader left legal
|
||||||
|
lemon length lesson letter level liar libya lid life light like limit line lion liquid list little live lizard load
|
||||||
|
local logic long loop lost loud love low loyal lucky lumber lunch lust luxury lyrics mad magic main major make male
|
||||||
|
mammal man map market mass matter maze mccoy meadow media meet melt member men mercy mesh method middle milk mimic mind
|
||||||
|
mirror miss mix mobile model mom monkey moon more mother mouse move much muffin mule must mutual myself myth naive name
|
||||||
|
napkin narrow nasty nation near neck need nephew nerve nest net never news next nice night noble noise noodle normal
|
||||||
|
nose note novel now number nurse nut oak obey object oblige obtain occur ocean odor off often oil okay old olive omit
|
||||||
|
once one onion online open opium oppose option orange orbit order organ orient orphan other outer oval oven own oxygen
|
||||||
|
oyster ozone pact paddle page pair palace panel paper parade past path pause pave paw pay peace pen people pepper permit
|
||||||
|
pet philip phone phrase piano pick piece pig pilot pink pipe pistol pitch pizza place please pluck poem point polar pond
|
||||||
|
pool post pot pound powder praise prefer price profit public pull punch pupil purity push put puzzle qatar quasi queen
|
||||||
|
quite quoted rabbit race radio rail rally ramp range rapid rare rather raven raw razor real rebel recall red reform
|
||||||
|
region reject relief remain rent reopen report result return review reward rhythm rib rich ride rifle right ring riot
|
||||||
|
ripple risk ritual river road robot rocket room rose rotate round row royal rubber rude rug rule run rural sad safe sage
|
||||||
|
sail salad same santa sauce save say scale scene school scope screen scuba sea second seed self semi sense series settle
|
||||||
|
seven shadow she ship shock shrimp shy sick side siege sign silver simple since siren sister six size skate sketch ski
|
||||||
|
skull slab sleep slight slogan slush small smile smooth snake sniff snow soap soccer soda soft solid son soon sort south
|
||||||
|
space speak sphere spirit split spoil spring spy square state step still story strong stuff style submit such sudden
|
||||||
|
suffer sugar suit summer sun supply sure swamp sweet switch sword symbol syntax syria system table tackle tag tail talk
|
||||||
|
tank tape target task tattoo taxi team tell ten term test text that theme this three thumb tibet ticket tide tight tilt
|
||||||
|
time tiny tip tired tissue title toast today toe toilet token tomato tone tool top torch toss total toward toy trade
|
||||||
|
tree trial trophy true try tube tumble tunnel turn twenty twice two type ugly unable uncle under unfair unique unlock
|
||||||
|
until unveil update uphold upon upper upset urban urge usage use usual vacuum vague valid van vapor vast vault vein
|
||||||
|
velvet vendor very vessel viable video view villa violin virus visit vital vivid vocal voice volume vote voyage wage
|
||||||
|
wait wall want war wash water wave way wealth web weird were west wet what when whip wide wife will window wire wish
|
||||||
|
wolf woman wonder wood work wrap wreck write wrong xander xbox xerox xray yang yard year yellow yes yin york you zane
|
||||||
|
zara zebra zen zero zippo zone zoo zorro zulu
|
||||||
|
""".split()
|
||||||
|
assert len(words) == 1024 # Exactly 10 bits of entropy per word
|
256
static/app.js
256
static/app.js
@ -5,7 +5,10 @@ let currentUser = null
|
|||||||
let currentCredentials = []
|
let currentCredentials = []
|
||||||
let aaguidInfo = {}
|
let aaguidInfo = {}
|
||||||
|
|
||||||
// Session management - now using HTTP-only cookies
|
// ========================================
|
||||||
|
// Session Management
|
||||||
|
// ========================================
|
||||||
|
|
||||||
async function validateStoredToken() {
|
async function validateStoredToken() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/validate-token', {
|
const response = await fetch('/api/validate-token', {
|
||||||
@ -14,18 +17,12 @@ async function validateStoredToken() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const result = await response.json()
|
const result = await response.json()
|
||||||
|
return result.status === 'success'
|
||||||
if (result.status === 'success') {
|
|
||||||
return true
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to set session cookie using JWT token
|
|
||||||
async function setSessionCookie(sessionToken) {
|
async function setSessionCookie(sessionToken) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/set-session', {
|
const response = await fetch('/api/set-session', {
|
||||||
@ -48,7 +45,10 @@ async function setSessionCookie(sessionToken) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// View management
|
// ========================================
|
||||||
|
// View Management
|
||||||
|
// ========================================
|
||||||
|
|
||||||
function showView(viewId) {
|
function showView(viewId) {
|
||||||
document.querySelectorAll('.view').forEach(view => view.classList.remove('active'))
|
document.querySelectorAll('.view').forEach(view => view.classList.remove('active'))
|
||||||
document.getElementById(viewId).classList.add('active')
|
document.getElementById(viewId).classList.add('active')
|
||||||
@ -64,7 +64,11 @@ function showRegisterView() {
|
|||||||
clearStatus('registerStatus')
|
clearStatus('registerStatus')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update dashboard view to load user info
|
function showDeviceAdditionView() {
|
||||||
|
showView('deviceAdditionView')
|
||||||
|
clearStatus('deviceAdditionStatus')
|
||||||
|
}
|
||||||
|
|
||||||
function showDashboardView() {
|
function showDashboardView() {
|
||||||
showView('dashboardView')
|
showView('dashboardView')
|
||||||
clearStatus('dashboardStatus')
|
clearStatus('dashboardStatus')
|
||||||
@ -76,7 +80,10 @@ function showDashboardView() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status management
|
// ========================================
|
||||||
|
// Status Management
|
||||||
|
// ========================================
|
||||||
|
|
||||||
function showStatus(elementId, message, type = 'info') {
|
function showStatus(elementId, message, type = 'info') {
|
||||||
const statusEl = document.getElementById(elementId)
|
const statusEl = document.getElementById(elementId)
|
||||||
statusEl.innerHTML = `<div class="status ${type}">${message}</div>`
|
statusEl.innerHTML = `<div class="status ${type}">${message}</div>`
|
||||||
@ -86,6 +93,161 @@ function clearStatus(elementId) {
|
|||||||
document.getElementById(elementId).innerHTML = ''
|
document.getElementById(elementId).innerHTML = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// Device Addition & QR Code
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
async function generateAndShowDeviceLink() {
|
||||||
|
showView('deviceAdditionView')
|
||||||
|
clearStatus('deviceAdditionStatus')
|
||||||
|
|
||||||
|
try {
|
||||||
|
showStatus('deviceAdditionStatus', 'Generating device link...', 'info')
|
||||||
|
|
||||||
|
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
|
||||||
|
document.getElementById('deviceLinkText').textContent = result.addition_link
|
||||||
|
document.getElementById('deviceToken').textContent = result.token
|
||||||
|
|
||||||
|
// Store link globally for copy function
|
||||||
|
window.currentDeviceLink = result.addition_link
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
const qrCodeContainer = document.getElementById('qrCode')
|
||||||
|
try {
|
||||||
|
if (typeof QRCode === 'undefined') {
|
||||||
|
throw new Error('QRCode library not loaded')
|
||||||
|
}
|
||||||
|
|
||||||
|
qrCodeContainer.innerHTML = ''
|
||||||
|
|
||||||
|
new QRCode(qrCodeContainer, {
|
||||||
|
text: result.addition_link,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
colorDark: '#000000',
|
||||||
|
colorLight: '#ffffff',
|
||||||
|
correctLevel: QRCode.CorrectLevel.M
|
||||||
|
})
|
||||||
|
} catch (qrError) {
|
||||||
|
console.error('QR code generation failed:', qrError)
|
||||||
|
qrCodeContainer.innerHTML = `
|
||||||
|
<div style="font-family: monospace; font-size: 12px; line-height: 1; background: white; padding: 10px; border: 1px solid #ccc; display: inline-block;">
|
||||||
|
QR Code generation failed. Use the link below instead.
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
showStatus('deviceAdditionStatus', 'Device link generated successfully!', 'success')
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
showStatus('deviceAdditionStatus', `Failed to generate device link: ${error.message}`, 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// User registration
|
||||||
async function register(user_name) {
|
async function register(user_name) {
|
||||||
try {
|
try {
|
||||||
@ -204,6 +366,9 @@ function updateUserInfo() {
|
|||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
userInfoEl.innerHTML = `
|
userInfoEl.innerHTML = `
|
||||||
<h3>👤 ${currentUser.user_name}</h3>
|
<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>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -306,75 +471,6 @@ async function checkExistingSession() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new credential for logged-in user
|
|
||||||
async function addNewCredential() {
|
|
||||||
try {
|
|
||||||
showStatus('dashboardStatus', 'Starting new passkey registration...', 'info')
|
|
||||||
|
|
||||||
const ws = await aWebSocket('/ws/add_credential')
|
|
||||||
|
|
||||||
// Registration chat - no need to send user data since we're authenticated
|
|
||||||
const optionsJSON = JSON.parse(await ws.recv())
|
|
||||||
if (optionsJSON.error) throw new Error(optionsJSON.error)
|
|
||||||
|
|
||||||
showStatus('dashboardStatus', '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()
|
|
||||||
|
|
||||||
showStatus('dashboardStatus', 'New passkey added successfully!', 'success')
|
|
||||||
|
|
||||||
// Refresh credentials list to show the new credential
|
|
||||||
await loadCredentials()
|
|
||||||
clearStatus('dashboardStatus')
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
showStatus('dashboardStatus', 'Registration cancelled', 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete credential
|
|
||||||
async function deleteCredential(credentialId) {
|
|
||||||
if (!confirm('Are you sure you want to delete this passkey? This action cannot be undone.')) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
showStatus('dashboardStatus', 'Deleting passkey...', 'info')
|
|
||||||
|
|
||||||
const response = await fetch('/api/delete-credential', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
credentials: 'include',
|
|
||||||
body: JSON.stringify({
|
|
||||||
credential_id: credentialId
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
showStatus('dashboardStatus', 'Passkey deleted successfully!', 'success')
|
|
||||||
|
|
||||||
// Refresh credentials list
|
|
||||||
await loadCredentials()
|
|
||||||
clearStatus('dashboardStatus')
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
showStatus('dashboardStatus', `Failed to delete passkey: ${error.message}`, 'error')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Form event handlers
|
// Form event handlers
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Check for existing session on page load
|
// Check for existing session on page load
|
||||||
|
@ -2,244 +2,10 @@
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>Passkey Authentication</title>
|
<title>Passkey Authentication</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js"></script>
|
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.min.js"></script>
|
||||||
|
<script src="/static/qrcodejs/qrcode.min.js"></script>
|
||||||
<script src="/static/awaitable-websocket.js"></script>
|
<script src="/static/awaitable-websocket.js"></script>
|
||||||
<style>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@ -282,10 +48,43 @@
|
|||||||
<button onclick="addNewCredential()" class="btn-primary">
|
<button onclick="addNewCredential()" class="btn-primary">
|
||||||
Add New Passkey
|
Add New Passkey
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="generateAndShowDeviceLink()" class="btn-secondary">
|
||||||
|
Generate Device Link
|
||||||
|
</button>
|
||||||
<button onclick="logout()" class="btn-danger">
|
<button onclick="logout()" class="btn-danger">
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<script src="static/app.js"></script>
|
<script src="static/app.js"></script>
|
||||||
|
4
static/qrcodejs/.gitignore
vendored
Normal file
4
static/qrcodejs/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.DS_Store
|
||||||
|
|
||||||
|
.idea
|
||||||
|
.project
|
14
static/qrcodejs/LICENSE
Normal file
14
static/qrcodejs/LICENSE
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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.
|
46
static/qrcodejs/README.md
Normal file
46
static/qrcodejs/README.md
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
# 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")
|
||||||
|
|
18
static/qrcodejs/bower.json
Normal file
18
static/qrcodejs/bower.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
47
static/qrcodejs/index-svg.html
Normal file
47
static/qrcodejs/index-svg.html
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<!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>
|
44
static/qrcodejs/index.html
Normal file
44
static/qrcodejs/index.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<!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>
|
37
static/qrcodejs/index.svg
Normal file
37
static/qrcodejs/index.svg
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?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>
|
After Width: | Height: | Size: 1.1 KiB |
2
static/qrcodejs/jquery.min.js
vendored
Normal file
2
static/qrcodejs/jquery.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
614
static/qrcodejs/qrcode.js
Normal file
614
static/qrcodejs/qrcode.js
Normal file
@ -0,0 +1,614 @@
|
|||||||
|
/**
|
||||||
|
* @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
Normal file
1
static/qrcodejs/qrcode.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
185
static/reset.html
Normal file
185
static/reset.html
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Add Device - Passkey Authentication</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
<script src="https://unpkg.com/@simplewebauthn/browser/dist/bundle/index.umd.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>
|
343
static/style.css
Normal file
343
static/style.css
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user