Asp.net webapi와 Angular, Sqlite를 이용한 Jwt Login, Todo 만들기. #2
유튜브 : https://youtu.be/18YdKq7JvMs
Github : https://github.com/tigersarang/AspnetAngJwtSqliteTodo
참고 사이트 : https://www.udemy.com/share/101WMM3@BinRVzIc_oHnJ4yrGuBmUbzQ_OVNQifnzbfKlpDAVFGCTCIuCBysacegf38ijijm5g==/
angular 설치
ng new todo-app
1. 패키지 설치
npm install -force ngx-bootstrap bootstrap ngx-toastr @angular/animations @popperjs/core2. angular.json에 css, js 추가하기
"styles": ["node_modules/ngx-toastr/toastr.css","node_modules/bootstrap/dist/css/bootstrap.min.css","src/styles.css"],"scripts": ["node_modules/@popperjs/core/dist/umd/popper.min.js","node_modules/bootstrap/dist/js/bootstrap.min.js"]
3. app.config.ts에 provider 추가import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';import { provideRouter } from '@angular/router';import { routes } from './app.routes';import { provideHttpClient, withInterceptors } from '@angular/common/http';import {provideAnimations} from '@angular/platform-browser/animations';import { provideToastr } from 'ngx-toastr';import { authInterceptor } from './_interceptor/auth.interceptor';export const appConfig: ApplicationConfig = {providers: [provideZoneChangeDetection({ eventCoalescing: true }),provideRouter(routes),provideHttpClient(withInterceptors([authInterceptor])),provideAnimations(),provideToastr({positionClass: 'toast-bottom-right',timeOut: 5000,closeButton: true})]};4. app.component.ts 수정import { BsDropdownModule } from 'ngx-bootstrap/dropdown';
@Component({
selector: 'app-root',
imports: [RouterOutlet, BsDropdownModule, NavComponent],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent implements OnInit {
title = 'todo-app';
private customerService = inject(CustomerService);
private autoLogoutService = inject(AutoLogoutService);
setCurrentUser() {
const userString = localStorage.getItem('user');
if (userString) {
const user = JSON.parse(userString);
this.customerService.currentUser.set(user);
}
this.autoLogoutService.init();
}
ngOnInit(): void {
this.setCurrentUser();
}
}
5. app.component.html 수정
<app-nav></app-nav>
<router-outlet></router-outlet>
6. environment 파일 생성
ng g environments
environment.development.ts
export const environment = {
apiUrl: 'http://localhost:5213/api/'
};
7. app.routes.ts 수정
export const routes: Routes = [
{path:'home', component: HomeComponent},
{path:'register', component: RegisterComponent},
{path:'login', component: LoginComponent},
{path:'todo', component: TodoListComponent},
{path:'todoAdd', component: TodoRegisterComponent},
{path:'todoEdit', component: TodoEditComponent},
{path:'todoDetail/:id', component: TodoDetailComponent},
{path:'**', redirectTo: 'home', pathMatch:'full'}
];
8. model 생성
ng g i _models/todo
ng g i _models/customer
export interface Customer {
id: number;
userName: string;
email: string;
password: string;
token: string;
}
export interface Todo {
id: number;
title: string;
description: string;
isCompleted: boolean;
}
9. service 생성
ng g s _services/todo
ng g s _services/customer
ng g s _services/auto-logout
export class TodoService {
private http = inject(HttpClient);
apiUrl = environment.apiUrl;
private todoToEditSource = new BehaviorSubject<Todo | null>(null);
currentTodoToEdit = this.todoToEditSource.asObservable();
add(todoItem : Todo) {
return this.http.post<Todo>(this.apiUrl + 'todo/add', todoItem);
}
list() {
return this.http.get(this.apiUrl + 'todo/all');
}
get(id: number) {
return this.http.get(this.apiUrl + 'todo/' + id);
}
delete(id: number) {
return this.http.delete(this.apiUrl + 'todo/'+ id);
}
update(todoItem: Todo) {
return this.http.put<Todo>(this.apiUrl + 'todo/update', todoItem);
}
sendTodo(todo: Todo) {
this.todoToEditSource.next(todo);
}
}
----------------------------------------------------------------------
export class CustomerService {
private http = inject(HttpClient);
apiUrl = environment.apiUrl;
currentUser = signal<Customer | null>(null);
register(customer: Customer) {
return this.http.post<Customer>(this.apiUrl + 'Customer/register', customer).pipe(
map(user => {
localStorage.setItem('user', JSON.stringify(user));
this.currentUser.set(user);
})
)
}
login(customer: Customer) {
return this.http.post<Customer>(this.apiUrl + 'Customer/login', customer).pipe(
map(user => {
localStorage.setItem('user', JSON.stringify(user));
this.currentUser.set(user);
})
)
}
logout() {
localStorage.removeItem('user');
this.currentUser.set(null);
}
}
----------------------------------------------------------------------
const LOGOUT_MINUTE = 1;
const CHECK_INTERVAL = 3000;
const STORE_KEY = 'lastAction';
@Injectable({
providedIn: 'root'
})
export class AutoLogoutService {
private customerService = inject(CustomerService);
private router = inject(Router);
private ngZone = inject(NgZone);
private intervaliId: any;
constructor() {
this.init();
}
init() {
if (this.customerService.currentUser()) {
this.reset();
this.initListener();
this.initInterval();
}
}
public getLastAction() : number {
const lastAction = localStorage.getItem(STORE_KEY);
return lastAction ? parseInt(lastAction, 10) : Date.now();
}
public setLastAction(lastAction: number) : void {
localStorage.setItem(STORE_KEY, lastAction.toString());
}
reset() {
this.setLastAction(Date.now());
}
initListener() {
this.ngZone.runOutsideAngular(() => {
document.body.addEventListener('click', () => this.reset());
document.body.addEventListener('mouseover', () => this.reset());
document.body.addEventListener('mouseout', () => this.reset());
document.body.addEventListener('keydown', () => this.reset());
document.body.addEventListener('keyup', () => this.reset());
document.body.addEventListener('keypress', () => this.reset());
});
}
initInterval() {
console.log("initInterval : ");
this.ngZone.runOutsideAngular(() => {
this.intervaliId = setInterval(() => {
this.check();
}, CHECK_INTERVAL);
});
}
check() {
const now = Date.now();
const timeleft = this.getLastAction() + LOGOUT_MINUTE * 60 * 1000;
const diff = timeleft - now;
const isTimeout = diff < 0;
this.ngZone.run(() => {
console.log(`마지막 활동 후 ${LOGOUT_MINUTE}분이 지남`);
if (isTimeout && this.customerService.currentUser()) {
console.log(`마지막 활동 후 ${LOGOUT_MINUTE}분이 지남 logout 시작`);
this.customerService.logout();
if (this.intervaliId) {
clearInterval(this.intervaliId);
}
}
});
}
}
10. 컴포넌트 생성
참고 : FormsModule을 사용하는 곳에서는 다음 import를 추가해야 됩니다.
import { FormsModule } from '@angular/forms';
ng g c _components/customer/register
ng g c _components/customer/login
ng g c _components/home
ng g c _components/nav
ng g c _components/todo/todo_list
ng g c _components/todo/todo_register
ng g c _components/todo/todo_detail
ng g c _components/todo/todo_edit
@Component({
selector: 'app-register',
imports: [FormsModule],
templateUrl: './register.component.html',
styleUrl: './register.component.css'
})
export class RegisterComponent {
private customerService = inject(CustomerService);
model: any = {}
private toastrService = inject(ToastrService);
register() {
this.customerService.register(this.model).subscribe({
next: (user) => {
console.log(user);
this.toastrService.success('Success');
},
error: (error) => {
console.log(error);
this.toastrService.success('Error');
}
})
}
}
@Component({
selector: 'app-login',
imports: [FormsModule],
templateUrl: './login.component.html',
styleUrl: './login.component.css'
})
export class LoginComponent {
private customerService = inject(CustomerService);
model: any = {}
private toastrService = inject(ToastrService);
private router = inject(Router);
login() {
this.customerService.login(this.model).subscribe({
next: (user) => {
console.log(user);
this.toastrService.success('Success');
this.router.navigateByUrl("home");
},
error: (error) => {
console.log(error);
this.toastrService.success('Error');
}
})
}
}
export class HomeComponent {
}
export class NavComponent {
customerService = inject(CustomerService);
logout() {
this.customerService.logout();
}
}
export class TodoListComponent implements OnInit {
private todoService = inject(TodoService);
private toastrService = inject(ToastrService);
todos?: Todo[] = [];
model: any = {};
private router = inject(Router);
ngOnInit(): void {
this.list();
}
list() {
this.todoService.list().subscribe({
next: (todos: any) => {
this.todos = todos;
console.log('success');
console.log(todos);
this.toastrService.success('Success');
},
error: (error) => {
console.log(error);
this.toastrService.success('Fail');
}
})
}
delete(id: number) {
console.log("id : " + id);
this.todoService.delete(id).subscribe({
next: _ => {
this.todos = this.todos?.filter(todo => todo.id !== id);
this.toastrService.success('success');
},
error: (error) => {
console.log(error);
this.toastrService.error(error);
}
})
}
edit(todo: Todo) {
this.router.navigate(['todoEdit']);
this.todoService.sendTodo(todo);
}
}
@Component({
selector: 'app-todo-register',
imports: [FormsModule],
templateUrl: './todo-register.component.html',
styleUrl: './todo-register.component.css'
})
export class TodoRegisterComponent {
private todoService = inject(TodoService);
private toastrService = inject(ToastrService);
private router = inject(Router);
model: any = {};
add() {
this.todoService.add(this.model).subscribe({
next:() => {
console.log('success');
this.toastrService.success('Success');
this.router.navigateByUrl('todo');
},
error: (error) => {
alert('fail');
console.log(error);
this.toastrService.success('Fail');
}
})
}
}
@Component({
selector: 'app-todo-detail',
imports: [FormsModule, CommonModule],
templateUrl: './todo-detail.component.html',
styleUrl: './todo-detail.component.css'
})
export class TodoDetailComponent implements OnInit {
private todoService = inject(TodoService);
private toastrService = inject(ToastrService);
todo: any = {};
private route = inject(ActivatedRoute);
ngOnInit(): void {
this.route.paramMap.subscribe(
params => {
const idParam = params.get("id");
if (idParam) {
this.get(Number(idParam));
}
}
)
}
get(id: number) {
this.todoService.get(id).subscribe({
next: (todo: any) => {
this.todo = todo;
console.log('success');
this.toastrService.success('Success');
},
error: (error) => {
console.log(error);
this.toastrService.success('Fail');
}
})
}
}
@Component({
selector: 'app-todo-edit',
imports: [FormsModule],
templateUrl: './todo-edit.component.html',
styleUrl: './todo-edit.component.css'
})
export class TodoEditComponent implements OnInit, OnDestroy {
private todoService = inject(TodoService);
private toastrService = inject(ToastrService);
private todoSubscription: Subscription | undefined;
private router = inject(Router);
todoEdit?: Todo;
update() {
if (this.todoEdit === undefined) {
this.toastrService.error("Todo 정보 오류");
return;
}
this.todoService.update(this.todoEdit).subscribe({
next: (todo) => {
this.toastrService.success("success");
this.router.navigateByUrl('todo');
},
error: (error) => {
this.toastrService.error(error);
}
})
}
ngOnInit(): void {
console.log('todoedit');
this.todoSubscription = this.todoService.currentTodoToEdit.subscribe(
todo => {
if (todo) {
this.todoEdit = todo;
console.log('받은 todo : ' + this.todoEdit);
}
}
)
}
ngOnDestroy(): void {
if (this.todoSubscription) {
this.todoSubscription.unsubscribe();
}
}
}
11. Interceptor 생성
ng g interceptor _interceptor/auth
export const authInterceptor: HttpInterceptorFn = (req, next) => {
var userString = localStorage.getItem("user");
console.log('authIntercepter...');
if (!userString) return next(req);
var user = JSON.parse(userString);
var token = user.token;
if (token) {
req = req.clone({
setHeaders: {
Authorization: `Bearer ${token}`
}
});
}
return next(req);
};
12. 화면 작성
login.component.html
<main class="form-signin w-100 m-auto">
<form #registerForm="ngForm" (ngSubmit)="login()">
<h1 class="h3 mb-3 fw-normal">로그인</h1>
<div class="form-floating">
<input
type="text"
name="userName"
[(ngModel)]="model.userName"
class="form-control"
id="floatingInput"
placeholder="name@example.com"
/>
<label for="floatingInput">성명</label>
</div>
<div class="form-floating">
<input
type="password"
class="form-control"
name="password"
[(ngModel)]="model.password"
id="floatingPassword"
placeholder="Password"
/>
<label for="floatingPassword">Password</label>
</div>
<div class="form-check text-start my-3">
<input
class="form-check-input"
type="checkbox"
value="remember-me"
id="checkDefault"
/>
<label class="form-check-label" for="checkDefault"> Remember me </label>
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Sign in</button>
<p class="mt-5 mb-3 text-body-secondary">© 2017–2025</p>
</form>
</main>
html,
body {
height: 100%;
}
.form-signin {
max-width: 530px;
padding: 1rem;
}
.form-signin .form-floating:focus-within {
z-index: 2;
}
.form-signin input[type="email"] {
margin-bottom: -1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.form-signin input[type="password"] {
margin-bottom: 10px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
<main class="form-signin w-100 m-auto">
<form #registerForm="ngForm" (ngSubmit)="register()">
<h1 class="h3 mb-3 fw-normal">회원가입</h1>
<div class="form-floating">
<input
type="text"
name="userName"
[(ngModel)]="model.userName"
class="form-control"
id="floatingInput"
placeholder="name@example.com"
/>
<label for="floatingInput">성명</label>
</div>
<div class="form-floating">
<input
type="email"
class="form-control"
name="email"
[(ngModel)]="model.email"
id="floatingEmail"
placeholder="name@example.com"
/>
<label for="floatingEmail">Email address</label>
</div>
<div class="form-floating">
<input
type="password"
class="form-control"
name="password"
[(ngModel)]="model.password"
id="floatingPassword"
placeholder="Password"
/>
<label for="floatingPassword">Password</label>
</div>
<div class="form-check text-start my-3">
<input
class="form-check-input"
type="checkbox"
value="remember-me"
id="checkDefault"
/>
<label class="form-check-label" for="checkDefault"> Remember me </label>
</div>
<button class="btn btn-primary w-100 py-2" type="submit">Sign in</button>
<p class="mt-5 mb-3 text-body-secondary">© 2017–2025</p>
</form>
</main>
html,
body {
height: 100%;
}
.form-signin {
max-width: 530px;
padding: 1rem;
}
.form-signin .form-floating:focus-within {
z-index: 2;
}
.form-signin input[type="email"] {
margin-bottom: -1px;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.form-signin input[type="password"] {
margin-bottom: 10px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
home.component.html
<main class="container">
<div class="p-4 p-md-5 mb-4 rounded text-body-emphasis bg-body-secondary">
<div class="col-lg-6 px-0">
<h1 class="display-4 fst-italic">Title of a longer featured blog post</h1>
<p class="lead my-3">
Multiple lines of text that form the lede,
</p>
<p class="lead mb-0">
<a href="#" class="text-body-emphasis fw-bold">Continue reading...</a>
</p>
</div>
</div>
<div class="row g-5">
<div class="col-md-8">
<h3 class="pb-4 mb-4 fst-italic border-bottom">From the Firehose</h3>
<article class="blog-post">
<h2 class="display-5 link-body-emphasis mb-1">Sample blog post</h2>
<p class="blog-post-meta">January 1, 2021 by <a href="#">Mark</a></p>
<p>
This blog post shows a few different types of content that’s supported
and styled with Bootstrap. Basic typography, lists, tables, images,
code, and more are all supported as expected.
</p>
<hr />
<p>
This is some additional paragraph placeholder content.
</p>
<pre><code>Example code block</code></pre>
<p>
This is some additional paragraph placeholder content. It's a slightly
shorter version of the other highly repetitive body text used
throughout.
</p>
</article>
<nav class="blog-pagination" aria-label="Pagination">
<a class="btn btn-outline-primary rounded-pill" href="#">Older</a>
<a
class="btn btn-outline-secondary rounded-pill disabled"
aria-disabled="true"
>Newer</a
>
</nav>
</div>
<div class="col-md-4">
<div class="position-sticky" style="top: 2rem">
<div class="p-4 mb-3 bg-body-tertiary rounded">
<h4 class="fst-italic">About</h4>
<p class="mb-0">
Customize this section to tell your visitors a little bit about your
publication, writers, content, or something else entirely. Totally
up to you.
</p>
</div>
<div>
<h4 class="fst-italic">Recent posts</h4>
<ul class="list-unstyled">
<li>
<a
class="d-flex flex-column flex-lg-row gap-3 align-items-start align-items-lg-center py-3 link-body-emphasis text-decoration-none border-top"
href="#"
>
<svg
aria-hidden="true"
class="bd-placeholder-img"
height="96"
preserveAspectRatio="xMidYMid slice"
width="100%"
xmlns="http://www.w3.org/2000/svg"
>
<rect width="100%" height="100%" fill="#777"></rect>
</svg>
<div class="col-lg-8">
<h6 class="mb-0">Example blog post title</h6>
<small class="text-body-secondary">January 15, 2024</small>
</div>
</a>
</li>
</ul>
</div>
<div class="p-4">
<h4 class="fst-italic">Elsewhere</h4>
<ol class="list-unstyled">
<li><a href="#">GitHub</a></li>
<li><a href="#">Social</a></li>
<li><a href="#">Facebook</a></li>
</ol>
</div>
</div>
</div>
</div>
</main>
<div class="container">
<header class="border-bottom lh-1 py-3">
<div class="row flex-nowrap justify-content-between align-items-center">
@if (!customerService.currentUser()) {
<div class="col-4 pt-1">
<a class="link-secondary" href="register">회원가입</a>
</div>
}
<div class="col-4 text-center">
<a
class="blog-header-logo text-body-emphasis text-decoration-none"
href="#"
>Todo 서비스</a
>
</div>
<div class="col-4 d-flex justify-content-end align-items-center">
<a class="link-secondary" href="#" aria-label="Search">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
class="mx-3"
role="img"
viewBox="0 0 24 24"
>
<title>Search</title>
<circle cx="10.5" cy="10.5" r="7.5"></circle>
<path d="M21 21l-5.2-5.2"></path>
</svg>
</a>
@if (!customerService.currentUser()) {
<a class="btn btn-sm btn-outline-secondary" href="login">로그인</a>
}
@else {
<a href="" (click)="logout()">로그아웃</a>
}
</div>
</div>
</header>
<div class="nav-scroller py-1 mb-3 border-bottom">
<nav class="nav nav-underline justify-content-between">
<a class="nav-item nav-link link-body-emphasis active" href="todo">Todo</a>
<a class="nav-item nav-link link-body-emphasis" href="#">U.S.</a>
<a class="nav-item nav-link link-body-emphasis" href="#">Design</a>
<a class="nav-item nav-link link-body-emphasis" href="#">Culture</a>
<a class="nav-item nav-link link-body-emphasis" href="#">Business</a>
<a class="nav-item nav-link link-body-emphasis" href="#">Politics</a>
<a class="nav-item nav-link link-body-emphasis" href="#">Opinion</a>
<a class="nav-item nav-link link-body-emphasis" href="#">Science</a>
<a class="nav-item nav-link link-body-emphasis" href="#">Health</a>
<a class="nav-item nav-link link-body-emphasis" href="#">Style</a>
<a class="nav-item nav-link link-body-emphasis" href="#">Travel</a>
</nav>
</div>
</div>
<div class="container mt-5">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h2 class="mb-0">Todo Details</h2>
</div>
<div class="card-body">
@if (todo) {
<h3 class="card-title">{{ todo.title }}</h3>
<p class="card-text">
<strong>Description:</strong> {{ todo.description }}
</p>
<p class="card-text">
<strong>Status:</strong>
<span
class="badge"
[ngClass]="todo.isCompleted ? 'bg-success' : 'bg-danger'"
>
{{ todo.isCompleted ? "Completed" : "Pending" }}
</span>
</p>
<hr />
<div class="d-flex justify-content-between">
<a href="todo" class="btn btn-secondary">Back to List</a>
</div>
}
</div>
</div>
</div>
<div class="container mt-5">
<div class="row justify-content-center">
@if (todoEdit) {
<div class="col-md-8 col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title text-center">Edit Todo</h3>
</div>
<div class="card-body">
<form #registerTodo="ngForm" (ngSubmit)="update()">
<div class="mb-3">
<label for="todoTitle" class="form-label">Title</label>
<input type="text" class="form-control" [(ngModel)]="todoEdit.title" name="title" required>
</div>
<div class="mb-3">
<label for="todoDescription" class="form-label">Description</label>
<textarea class="form-control" [(ngModel)]="todoEdit.description" name="description" rows="3"></textarea>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" [(ngModel)]="todoEdit.isCompleted" name="isCompleted">
<label class="form-check-label" for="todoIsCompleted">Is Completed?</label>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary" >Register Todo</button>
</div>
</form>
</div>
</div>
</div>
}
</div>
</div>
todo-list.component.html
<div class="container mt-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Todo List</h2>
<a href="todoAdd" class="btn btn-primary">Add New Todo</a>
</div>
---
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Title</th>
<th scope="col">Description</th>
<th scope="col">Completed</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody id="todoTableBody">
@for (item of todos; track item.id) {
<tr>
<td>{{item.id}}</td>
<td>{{item.title}}</td>
<td>{{item.description}}</td>
<td><span class="badge bg-success">{{item.isCompleted}}</span></td>
<td>
<a class="btn btn-info btn-sm" href="todoDetail/{{item.id}}">View</a>
<button class="btn btn-warning btn-sm" (click)="edit(item)" >Edit</button>
<button class="btn btn-danger btn-sm" (click)="delete(item.id)">Delete</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<p class="text-center mt-3" id="noTodosMessage" style="display: none;">No Todo items found.</p>
</div>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card">
<div class="card-header">
<h3 class="card-title text-center">Register New Todo</h3>
</div>
<div class="card-body">
<form #registerTodo="ngForm" (ngSubmit)="add()">
<div class="mb-3">
<label for="todoTitle" class="form-label">Title</label>
<input type="text" class="form-control" [(ngModel)]="model.title" name="title" required>
</div>
<div class="mb-3">
<label for="todoDescription" class="form-label">Description</label>
<textarea class="form-control" [(ngModel)]="model.description" name="description" rows="3"></textarea>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" [(ngModel)]="model.isCompleted" name="isCompleted">
<label class="form-check-label" for="todoIsCompleted">Is Completed?</label>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary" >Register Todo</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>